diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 79dec6b951e9..23b62cfad424 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -184,14 +184,6 @@ jobs:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
if: needs.select-tests.outputs.has-selected-tests == 'true'
- - name: Setup sentry env
- if: needs.select-tests.outputs.has-selected-tests == 'true'
- uses: ./.github/actions/setup-sentry
- id: setup
- with:
- mode: backend-ci
- skip-devservices: true
-
- name: Download selected tests artifact
if: needs.select-tests.outputs.has-selected-tests == 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
diff --git a/.github/workflows/scripts/calculate-backend-test-shards.py b/.github/workflows/scripts/calculate-backend-test-shards.py
index 46dcdafbad16..ca75354bfa3a 100755
--- a/.github/workflows/scripts/calculate-backend-test-shards.py
+++ b/.github/workflows/scripts/calculate-backend-test-shards.py
@@ -1,9 +1,18 @@
#!/usr/bin/env python3
+"""Calculate the number of backend test shards needed for CI.
+
+Uses AST-based static analysis to count tests instead of running
+pytest --collect-only, which requires importing every module and
+bootstrapping Django (~100s). AST parsing takes a few seconds.
+"""
+
+from __future__ import annotations
+
+import ast
import json
import math
import os
import re
-import subprocess
import sys
from pathlib import Path
@@ -12,85 +21,126 @@
MAX_SHARDS = 22
DEFAULT_SHARDS = MAX_SHARDS
+IGNORED_DIRS = frozenset(("tests/acceptance/", "tests/apidocs/", "tests/js/", "tests/tools/"))
+
+
+def _resolve(node: ast.expr, scope: dict[str, ast.expr]) -> ast.expr:
+ """Chase Name and Subscript references back to a concrete AST node."""
+ if isinstance(node, ast.Name) and node.id in scope:
+ return _resolve(scope[node.id], scope)
+ if (
+ isinstance(node, ast.Subscript)
+ and isinstance(node.value, ast.Name)
+ and isinstance(node.slice, ast.Constant)
+ and isinstance(node.slice.value, int)
+ and node.value.id in scope
+ ):
+ target = _resolve(scope[node.value.id], scope)
+ i = node.slice.value
+ if isinstance(target, (ast.List, ast.Tuple)) and 0 <= i < len(target.elts):
+ return _resolve(target.elts[i], scope)
+ return node
+
+
+def _parametrize_count(dec: ast.expr, scope: dict[str, ast.expr]) -> int | None:
+ """If *dec* is a ``@pytest.mark.parametrize``, return the case count."""
+ dec = _resolve(dec, scope)
+ if not isinstance(dec, ast.Call) or len(dec.args) < 2:
+ return None
+ f = dec.func
+ if not (
+ isinstance(f, ast.Attribute)
+ and f.attr == "parametrize"
+ and isinstance(f.value, ast.Attribute)
+ and f.value.attr == "mark"
+ and isinstance(f.value.value, ast.Name)
+ and f.value.value.id == "pytest"
+ ):
+ return None
+ argvals = _resolve(dec.args[1], scope)
+ return len(argvals.elts) if isinstance(argvals, (ast.List, ast.Tuple)) else None
+
+
+_TEST_FUNC_RE = re.compile(r"^\s*(?:async\s+)?def\s+test_", re.MULTILINE)
+
+
+def count_tests_in_file(filepath: Path) -> int:
+ """Count the test items *filepath* would produce.
+
+ Accounts for ``@pytest.mark.parametrize`` multipliers including
+ stacked decorators.
+ """
+ try:
+ source = filepath.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ return 0
+
+ # Fast path: no parametrize means each def test_ is exactly one test.
+ if "parametrize" not in source:
+ return len(_TEST_FUNC_RE.findall(source))
+
+ try:
+ tree = ast.parse(source, filename=str(filepath))
+ except SyntaxError:
+ return len(_TEST_FUNC_RE.findall(source))
+
+ scope: dict[str, ast.expr] = {}
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, ast.Assign):
+ for target in node.targets:
+ if isinstance(target, ast.Name):
+ scope[target.id] = node.value
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value:
+ scope[node.target.id] = node.value
+
+ total = 0
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(
+ "test_"
+ ):
+ counts = (
+ c for d in node.decorator_list if (c := _parametrize_count(d, scope)) is not None
+ )
+ total += math.prod(counts, start=1)
+ return total
+
def collect_test_count() -> int | None:
- """Collect the number of tests to run, either from selected files or full suite."""
+ """Count tests via AST analysis of test files."""
selected_tests_file = os.environ.get("SELECTED_TESTS_FILE")
if selected_tests_file:
path = Path(selected_tests_file)
if not path.exists():
- print(f"Selected tests file not found: {selected_tests_file}", file=sys.stderr)
+ print(
+ f"Selected tests file not found: {selected_tests_file}",
+ file=sys.stderr,
+ )
return None
- with path.open() as f:
- selected_files = [line.strip() for line in f if line.strip()]
+ test_files = [Path(line.strip()) for line in path.read_text().splitlines() if line.strip()]
- if not selected_files:
+ if not test_files:
print("No selected test files, running 0 tests", file=sys.stderr)
return 0
- print(f"Counting tests in {len(selected_files)} selected files", file=sys.stderr)
-
- pytest_args = [
- "pytest",
- # Always pass tests/ directory to ensure proper conftest loading order.
- # SELECTED_TESTS_FILE env var triggers filtering in pytest_collection_modifyitems.
- "tests",
- "--collect-only",
- "--quiet",
- "--ignore=tests/acceptance",
- "--ignore=tests/apidocs",
- "--ignore=tests/js",
- "--ignore=tests/tools",
- ]
+ print(f"Counting tests in {len(test_files)} selected files", file=sys.stderr)
+ else:
+ tests_dir = Path("tests")
+ if not tests_dir.is_dir():
+ print("tests/ directory not found", file=sys.stderr)
+ return None
- try:
- result = subprocess.run(
- pytest_args,
- capture_output=True,
- text=True,
- check=False,
+ test_files = sorted(
+ p
+ for p in tests_dir.rglob("test_*.py")
+ if not any(str(p).startswith(d) for d in IGNORED_DIRS)
)
+ print(f"Found {len(test_files)} test files", file=sys.stderr)
- # Parse output for test count
- # Format without deselection: "27000 tests collected in 18.53s"
- # Format with deselection: "29/31510 tests collected (31481 deselected) in 18.13s"
- output = result.stdout + result.stderr
-
- # Try format with deselection first (selected/total)
- match = re.search(r"(\d+)/\d+ tests? collected", output)
- if match:
- count = int(match.group(1))
- print(f"Collected {count} tests", file=sys.stderr)
- return count
-
- # Fall back to format without deselection
- match = re.search(r"(\d+) tests? collected", output)
- if match:
- count = int(match.group(1))
- print(f"Collected {count} tests", file=sys.stderr)
- return count
-
- if result.returncode == 5:
- # Exit code 5 indicates no tests collected (https://docs.pytest.org/en/stable/reference/exit-codes.html)
- # This can stem from files being deleted in a branch/PR.
- print("No tests collected (exit 5)", file=sys.stderr)
- return 0
-
- if result.returncode != 0:
- print(
- f"Pytest collection failed (exit {result.returncode})",
- file=sys.stderr,
- )
- print(result.stderr, file=sys.stderr)
- return None
-
- print("No tests collected", file=sys.stderr)
- return 0
- except Exception as e:
- print(f"Error collecting tests: {e}", file=sys.stderr)
- return None
+ total = sum(count_tests_in_file(f) for f in test_files)
+ print(f"Counted {total} tests via AST analysis", file=sys.stderr)
+ return total
def calculate_shards(test_count: int | None) -> int:
diff --git a/.github/workflows/scripts/compute-sentry-selected-tests.py b/.github/workflows/scripts/compute-sentry-selected-tests.py
index 24d1c626f8ca..b5912bb05c2e 100644
--- a/.github/workflows/scripts/compute-sentry-selected-tests.py
+++ b/.github/workflows/scripts/compute-sentry-selected-tests.py
@@ -41,12 +41,36 @@
"tests/integration/",
)
+# Most of these won't have coverage info because they're evaluated at
+# module load time and app warmup, before any per-test coverage context is active.
+#
+# Tracking a "startup" coverage context doesn't work: django.setup()
+# eagerly imports models, fields, validators, utils, etc. We also have
+# large dynamic __init__'s so a startup context would select nearly every
+# test.
FULL_SUITE_TRIGGERS: list[str | re.Pattern[str]] = [
- "src/sentry/testutils/pytest/sentry.py",
+ re.compile(r"^src/sentry/testutils/pytest/"),
+ re.compile(r"(^|/)conftest\.py$"),
+ "src/sentry/runner/initializer.py",
"src/sentry/constants.py",
- "pyproject.toml",
+ # option defaults registered at startup via initialize_app()
+ re.compile(r"^src/sentry/options/"),
+ # feature flags registered via manager.add() at import time
+ re.compile(r"^src/sentry/features/"),
+ # signal definitions created at module level; receivers depend on these
+ "src/sentry/signals.py",
+ # signal handlers registered globally via initialize_receivers()
+ re.compile(r"^src/sentry/receivers/"),
+ # stdlib/third-party monkey-patches applied before Django setup
+ re.compile(r"^src/sentry/monkey/"),
+ # monkeypatches transaction.atomic for silo-aware DB routing
+ re.compile(r"^src/sentry/silo/patches/"),
+ # SiloRouter loaded via DATABASE_ROUTERS; affects every DB query
+ "src/sentry/db/router.py",
"src/sentry/conf/server.py",
"src/sentry/web/urls.py",
+ "pyproject.toml",
+ "uv.lock",
re.compile(r"/migrations/\d{4}_[^/]+\.py$"),
]
diff --git a/.github/workflows/scripts/test_calculate_backend_test_shards.py b/.github/workflows/scripts/test_calculate_backend_test_shards.py
new file mode 100644
index 000000000000..fbba4260072e
--- /dev/null
+++ b/.github/workflows/scripts/test_calculate_backend_test_shards.py
@@ -0,0 +1,494 @@
+from __future__ import annotations
+
+import importlib
+import textwrap
+from pathlib import Path
+
+import pytest
+
+# Module has a hyphen in its name, so use importlib.
+_mod = importlib.import_module("calculate-backend-test-shards")
+count_tests_in_file = _mod.count_tests_in_file
+calculate_shards = _mod.calculate_shards
+collect_test_count = _mod.collect_test_count
+
+
+def _write(tmp_path: Path, source: str) -> Path:
+ p = tmp_path / "test_example.py"
+ p.write_text(textwrap.dedent(source))
+ return p
+
+
+class TestCountTestsInFile:
+ def test_plain_functions(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ def test_a():
+ pass
+
+ def test_b():
+ pass
+
+ def helper():
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_methods_in_class(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ class TestFoo:
+ def test_one(self):
+ pass
+
+ def test_two(self):
+ pass
+
+ def helper(self):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_parametrize_list(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize("x", [1, 2, 3])
+ def test_vals(x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 3
+
+ def test_parametrize_tuple(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize("x", (True, False))
+ def test_vals(x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_parametrize_with_pytest_param(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize(
+ "x",
+ [
+ pytest.param(1, id="one"),
+ pytest.param(2, id="two"),
+ ],
+ )
+ def test_vals(x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_parametrize_empty_list(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize("x", [])
+ def test_vals(x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 0
+
+ def test_stacked_parametrize(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize("a", [1, 2])
+ @pytest.mark.parametrize("b", ["x", "y", "z"])
+ def test_combo(a, b):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 6
+
+ def test_variable_reference(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ CASES = [1, 2, 3, 4]
+
+ @pytest.mark.parametrize("x", CASES)
+ def test_vals(x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 4
+
+ def test_annotated_assignment_variable(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ from typing import Any
+ import pytest
+
+ CASES: list[dict[str, Any]] = [{"a": 1}, {"a": 2}, {"a": 3}]
+
+ @pytest.mark.parametrize("case", CASES)
+ def test_vals(case):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 3
+
+ def test_stored_decorator(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ clients = pytest.mark.parametrize(
+ "client",
+ [
+ pytest.param("redis", id="redis"),
+ pytest.param("memcached", id="memcached"),
+ ],
+ )
+
+ @clients
+ def test_get(client):
+ pass
+
+ @clients
+ def test_set(client):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 4
+
+ def test_subscript_reference(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ CASES = (
+ ["query", "mode"],
+ [("q1", "m1"), ("q2", "m2"), ("q3", "m3")],
+ )
+
+ @pytest.mark.parametrize(CASES[0], CASES[1])
+ def test_query(query, mode):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 3
+
+ def test_unresolvable_parametrize_counts_as_one(self, tmp_path):
+ """Function calls and attribute access can't be resolved statically."""
+ p = _write(
+ tmp_path,
+ """\
+ import os
+ import pytest
+
+ @pytest.mark.parametrize("f", os.listdir("/tmp"))
+ def test_files(f):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 1
+
+ def test_mixed_resolvable_and_unresolvable(self, tmp_path):
+ """One stacked parametrize resolved, one not — resolved one still multiplies."""
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize("a", [1, 2])
+ @pytest.mark.parametrize("b", some_func())
+ def test_combo(a, b):
+ pass
+ """,
+ )
+ # [1, 2] resolves to 2; some_func() does not, so treated as 1.
+ # Total = 2 * 1 = 2.
+ assert count_tests_in_file(p) == 2
+
+ def test_parametrize_with_ids_kwarg(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize(
+ "enabled",
+ [True, False],
+ ids=["with_feature", "without_feature"],
+ )
+ def test_feature(enabled):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_async_def(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ async def test_async():
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 1
+
+ def test_non_test_functions_ignored(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ def helper():
+ pass
+
+ def setup_module():
+ pass
+
+ def teardown_function():
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 0
+
+ def test_empty_file(self, tmp_path):
+ p = tmp_path / "test_empty.py"
+ p.write_text("")
+ assert count_tests_in_file(p) == 0
+
+ def test_syntax_error(self, tmp_path):
+ p = tmp_path / "test_bad.py"
+ p.write_text("def test_a(:\n")
+ # Regex fast path still finds the def — best-effort count.
+ assert count_tests_in_file(p) == 1
+
+ def test_nonexistent_file(self, tmp_path):
+ p = tmp_path / "test_nope.py"
+ assert count_tests_in_file(p) == 0
+
+ def test_parametrize_tuple_argnames(self, tmp_path):
+ """Argnames passed as tuple instead of string."""
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.parametrize(
+ ("name", "value"),
+ [("a", 1), ("b", 2)],
+ )
+ def test_pairs(name, value):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 2
+
+ def test_class_with_parametrize_on_method(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ class TestMath:
+ @pytest.mark.parametrize("n", [1, 2, 3])
+ def test_square(self, n):
+ pass
+
+ def test_plain(self):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 4
+
+ def test_multiple_classes_and_functions(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ def test_standalone():
+ pass
+
+ class TestA:
+ def test_one(self):
+ pass
+
+ class TestB:
+ @pytest.mark.parametrize("x", [1, 2])
+ def test_two(self, x):
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 4
+
+ def test_non_parametrize_decorators_ignored(self, tmp_path):
+ p = _write(
+ tmp_path,
+ """\
+ import pytest
+
+ @pytest.mark.slow
+ @pytest.mark.django_db
+ def test_decorated():
+ pass
+ """,
+ )
+ assert count_tests_in_file(p) == 1
+
+
+class TestCalculateShards:
+ def test_none_returns_default(self):
+ assert calculate_shards(None) == 22
+
+ def test_zero_returns_zero(self):
+ assert calculate_shards(0) == 0
+
+ def test_small_count(self):
+ assert calculate_shards(100) == 1
+
+ def test_exact_boundary(self):
+ assert calculate_shards(300) == 1
+
+ def test_just_over_boundary(self):
+ assert calculate_shards(301) == 2
+
+ def test_large_count_capped(self):
+ assert calculate_shards(100_000) == 22
+
+ def test_mid_range(self):
+ # 1500 / 300 = 5
+ assert calculate_shards(1500) == 5
+
+ def test_rounds_up(self):
+ # 301 / 300 = 1.003 → ceil = 2
+ assert calculate_shards(301) == 2
+
+
+class TestCollectTestCount:
+ def test_selected_tests_file(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+
+ # Create two test files.
+ (tmp_path / "test_a.py").write_text("def test_one(): pass\ndef test_two(): pass\n")
+ (tmp_path / "test_b.py").write_text("def test_three(): pass\n")
+
+ selected = tmp_path / "selected.txt"
+ selected.write_text(f"{tmp_path / 'test_a.py'}\n{tmp_path / 'test_b.py'}\n")
+ monkeypatch.setenv("SELECTED_TESTS_FILE", str(selected))
+
+ assert collect_test_count() == 3
+
+ def test_selected_tests_file_empty(self, tmp_path, monkeypatch):
+ selected = tmp_path / "selected.txt"
+ selected.write_text("\n")
+ monkeypatch.setenv("SELECTED_TESTS_FILE", str(selected))
+
+ assert collect_test_count() == 0
+
+ def test_selected_tests_file_missing(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("SELECTED_TESTS_FILE", str(tmp_path / "nope.txt"))
+ assert collect_test_count() is None
+
+ def test_full_suite_walks_tests_dir(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("SELECTED_TESTS_FILE", raising=False)
+
+ tests = tmp_path / "tests"
+ tests.mkdir()
+ (tests / "test_foo.py").write_text("def test_a(): pass\n")
+
+ sub = tests / "sub"
+ sub.mkdir()
+ (sub / "test_bar.py").write_text("def test_b(): pass\ndef test_c(): pass\n")
+
+ assert collect_test_count() == 3
+
+ def test_full_suite_ignores_excluded_dirs(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("SELECTED_TESTS_FILE", raising=False)
+
+ tests = tmp_path / "tests"
+ tests.mkdir()
+ (tests / "test_ok.py").write_text("def test_a(): pass\n")
+
+ for excluded in ("acceptance", "apidocs", "js", "tools"):
+ d = tests / excluded
+ d.mkdir()
+ (d / "test_skip.py").write_text("def test_no(): pass\n")
+
+ assert collect_test_count() == 1
+
+ def test_ignored_dirs_prefix_does_not_over_match(self, tmp_path, monkeypatch):
+ """tests/js/ must not exclude tests/json/."""
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("SELECTED_TESTS_FILE", raising=False)
+
+ tests = tmp_path / "tests"
+ (tests / "js").mkdir(parents=True)
+ (tests / "js" / "test_skip.py").write_text("def test_no(): pass\n")
+ (tests / "json").mkdir()
+ (tests / "json" / "test_keep.py").write_text("def test_yes(): pass\n")
+
+ assert collect_test_count() == 1
+
+ def test_no_tests_dir(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("SELECTED_TESTS_FILE", raising=False)
+ assert collect_test_count() is None
+
+
+SENTRY_TESTS = Path(__file__).resolve().parent.parent.parent.parent / "tests"
+
+
+@pytest.mark.skipif(
+ not SENTRY_TESTS.is_dir(),
+ reason="sentry tests/ directory not found",
+)
+def test_all_sentry_test_files():
+ """Parse every test file in the repo — no crashes, every file returns >= 0."""
+ ignored = {"tests/acceptance", "tests/apidocs", "tests/js", "tests/tools"}
+ files = sorted(
+ p
+ for p in SENTRY_TESTS.rglob("test_*.py")
+ if not any(str(p.relative_to(SENTRY_TESTS.parent)).startswith(d) for d in ignored)
+ )
+ assert len(files) > 2000, f"expected >2000 test files, found {len(files)}"
+
+ failures = []
+ total = 0
+ for f in files:
+ try:
+ n = count_tests_in_file(f)
+ assert n >= 0
+ total += n
+ except Exception as exc:
+ failures.append((f, exc))
+
+ assert not failures, f"{len(failures)} files failed:\n" + "\n".join(
+ f" {f}: {e}" for f, e in failures[:20]
+ )
+ # Sanity-check: the sentry test suite has ~30k tests at the time of writing.
+ assert total > 29_000, f"total {total} seems too low"
diff --git a/src/sentry/api/serializers/models/rule.py b/src/sentry/api/serializers/models/rule.py
index 1a518cdfc172..0473baf6192c 100644
--- a/src/sentry/api/serializers/models/rule.py
+++ b/src/sentry/api/serializers/models/rule.py
@@ -796,9 +796,15 @@ def serialize(self, obj: Workflow, attrs, user, **kwargs) -> RuleSerializerRespo
"createdBy": attrs.get("created_by", None),
"environment": environment.name if environment is not None else None,
"projects": [p.slug for p in attrs["projects"]],
- "status": "active" if obj.enabled else "disabled",
- "snooze": "snooze" in attrs,
+ # Workflow.enabled is toggled by snooze-for-everyone, but "disabled" in the
+ # UI means a broken/misconfigured rule (matching legacy Rule.status/ObjectStatus).
+ # Snooze state is communicated via the snooze fields instead.
+ "status": "active",
+ "snooze": not obj.enabled,
}
+ if not obj.enabled:
+ workflow_rule["snoozeForEveryone"] = True
+
if "last_triggered" in attrs:
workflow_rule["lastTriggered"] = attrs["last_triggered"]
diff --git a/src/sentry/incidents/subscription_processor.py b/src/sentry/incidents/subscription_processor.py
index 6276ab2701ef..25751bbcc68e 100644
--- a/src/sentry/incidents/subscription_processor.py
+++ b/src/sentry/incidents/subscription_processor.py
@@ -15,6 +15,7 @@
get_comparison_aggregation_value,
get_crash_rate_alert_metrics_aggregation_value_helper,
)
+from sentry.incidents.utils.subscription_limits import is_metric_subscription_allowed
from sentry.incidents.utils.types import (
DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION,
AnomalyDetectionUpdate,
@@ -53,46 +54,6 @@ class MetricIssueDetectorConfig(TypedDict):
detection_type: Literal["static", "percent", "dynamic"]
-def has_downgraded(dataset: str, organization: Organization) -> bool:
- """
- Check if the organization has downgraded since the subscription was created.
- """
- supports_metrics_issues = features.has("organizations:incidents", organization)
- if dataset == Dataset.Events.value and not supports_metrics_issues:
- metrics.incr("incidents.alert_rules.ignore_update_missing_incidents")
- return True
-
- supports_performance_view = features.has("organizations:performance-view", organization)
- if dataset == Dataset.Transactions.value and not (
- supports_metrics_issues and supports_performance_view
- ):
- metrics.incr("incidents.alert_rules.ignore_update_missing_incidents_performance")
- return True
-
- supports_explore_view = features.has("organizations:visibility-explore-view", organization)
- if dataset == Dataset.EventsAnalyticsPlatform.value and not (
- supports_metrics_issues and supports_explore_view
- ):
- metrics.incr("incidents.alert_rules.ignore_update_missing_incidents_eap")
- return True
-
- if dataset == Dataset.PerformanceMetrics.value and not features.has(
- "organizations:on-demand-metrics-extraction", organization
- ):
- metrics.incr("incidents.alert_rules.ignore_update_missing_on_demand")
- return True
-
- if not supports_metrics_issues:
- # These should probably be downgraded, but we should know the impact first.
- metrics.incr(
- "incidents.alert_rules.no_incidents_not_downgraded",
- sample_rate=1.0,
- tags={"dataset": dataset},
- )
-
- return False
-
-
class SubscriptionProcessor:
"""
Class for processing subscription updates for workflow engine.
@@ -268,9 +229,20 @@ def process_update(self, subscription_update: QuerySubscriptionUpdate) -> bool:
dataset = self.subscription.snuba_query.dataset
organization = self.subscription.project.organization
- if has_downgraded(dataset, organization):
+ if not is_metric_subscription_allowed(dataset, organization):
+ metrics.incr(
+ "incidents.alert_rules.ignore_update_not_enabled",
+ tags={"dataset": dataset},
+ )
return False
+ if not features.has("organizations:incidents", organization):
+ metrics.incr(
+ "incidents.alert_rules.no_incidents_not_downgraded",
+ sample_rate=1.0,
+ tags={"dataset": dataset},
+ )
+
if subscription_update["timestamp"] <= self.last_update:
metrics.incr("incidents.alert_rules.skipping_already_processed_update")
return False
diff --git a/src/sentry/incidents/utils/subscription_limits.py b/src/sentry/incidents/utils/subscription_limits.py
index 92332aa5d6ab..fc4a4edae41a 100644
--- a/src/sentry/incidents/utils/subscription_limits.py
+++ b/src/sentry/incidents/utils/subscription_limits.py
@@ -1,7 +1,35 @@
+from __future__ import annotations
+
from django.conf import settings
-from sentry import options
+from sentry import features, options
from sentry.models.organization import Organization
+from sentry.snuba.dataset import Dataset
+
+
+def is_metric_subscription_allowed(dataset: str, organization: Organization) -> bool:
+ """
+ Check whether the given organization is allowed to have a metric alert
+ subscription for the given dataset.
+
+ Returns True if allowed, False if the organization lacks the required features
+ (e.g. after a plan downgrade).
+ """
+ has_incidents = features.has("organizations:incidents", organization)
+ if dataset == Dataset.Events.value:
+ return has_incidents
+
+ if dataset == Dataset.Transactions.value:
+ return has_incidents and features.has("organizations:performance-view", organization)
+
+ if dataset == Dataset.EventsAnalyticsPlatform.value:
+ return has_incidents and features.has("organizations:visibility-explore-view", organization)
+
+ if dataset == Dataset.PerformanceMetrics.value:
+ return features.has("organizations:on-demand-metrics-extraction", organization)
+
+ # Other datasets (e.g. Metrics/sessions) aren't gated here but probably should be.
+ return True
def get_max_metric_alert_subscriptions(organization: Organization) -> int:
diff --git a/src/sentry/middleware/viewer_context.py b/src/sentry/middleware/viewer_context.py
index b8b6b48cfe23..144fdd7a5940 100644
--- a/src/sentry/middleware/viewer_context.py
+++ b/src/sentry/middleware/viewer_context.py
@@ -2,6 +2,7 @@
from collections.abc import Callable
+from django.conf import settings
from django.http.request import HttpRequest
from django.http.response import HttpResponseBase
@@ -26,6 +27,12 @@ def ViewerContextMiddleware_impl(request: HttpRequest) -> HttpResponseBase:
if not enabled:
return get_response(request)
+ # This avoids touching user session, which means we avoid
+ # setting `Vary: Cookie` as a response header which will
+ # break HTTP caching entirely.
+ if request.path_info.startswith(settings.ANONYMOUS_STATIC_PREFIXES):
+ return get_response(request)
+
ctx = _viewer_context_from_request(request)
with viewer_context_scope(ctx):
return get_response(request)
diff --git a/src/sentry/models/project.py b/src/sentry/models/project.py
index 612fb7aa7c75..b6885c87a30d 100644
--- a/src/sentry/models/project.py
+++ b/src/sentry/models/project.py
@@ -881,7 +881,9 @@ def normalize_before_relocation_import(
def write_relocation_import(
self, scope: ImportScope, flags: ImportFlags
) -> tuple[int, ImportKind] | None:
- from sentry.receivers.project_detectors import disable_default_detector_creation
+ from sentry.workflow_engine.receivers.project_detectors import (
+ disable_default_detector_creation,
+ )
with disable_default_detector_creation():
return super().write_relocation_import(scope, flags)
diff --git a/src/sentry/notifications/defaults.py b/src/sentry/notifications/defaults.py
index 1c3edfc865f7..ac67a5312c83 100644
--- a/src/sentry/notifications/defaults.py
+++ b/src/sentry/notifications/defaults.py
@@ -24,6 +24,7 @@
NotificationSettingEnum.QUOTA_PROFILE_DURATION_UI: NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingEnum.QUOTA_SEER_BUDGET: NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingEnum.QUOTA_LOG_BYTES: NotificationSettingsOptionEnum.ALWAYS,
+ NotificationSettingEnum.QUOTA_TRACE_METRIC_BYTES: NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingEnum.QUOTA_SEER_USERS: NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingEnum.QUOTA_SIZE_ANALYSIS: NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingEnum.QUOTA_WARNINGS: NotificationSettingsOptionEnum.ALWAYS,
diff --git a/src/sentry/notifications/types.py b/src/sentry/notifications/types.py
index a68d5c222b32..09ced27513c7 100644
--- a/src/sentry/notifications/types.py
+++ b/src/sentry/notifications/types.py
@@ -34,6 +34,7 @@ class NotificationSettingEnum(ValueEqualityEnum):
QUOTA_SEER_BUDGET = "quotaSeerBudget"
QUOTA_SPEND_ALLOCATIONS = "quotaSpendAllocations"
QUOTA_LOG_BYTES = "quotaLogBytes"
+ QUOTA_TRACE_METRIC_BYTES = "quotaTraceMetricBytes"
QUOTA_SEER_USERS = "quotaSeerUsers"
QUOTA_SIZE_ANALYSIS = "quotaSizeAnalyses"
SPIKE_PROTECTION = "spikeProtection"
@@ -152,6 +153,10 @@ class UserOptionsSettingsKey(Enum):
NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingsOptionEnum.NEVER,
},
+ NotificationSettingEnum.QUOTA_TRACE_METRIC_BYTES: {
+ NotificationSettingsOptionEnum.ALWAYS,
+ NotificationSettingsOptionEnum.NEVER,
+ },
NotificationSettingEnum.QUOTA_SEER_USERS: {
NotificationSettingsOptionEnum.ALWAYS,
NotificationSettingsOptionEnum.NEVER,
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index 15b6342ed800..be1dca261c44 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -3575,18 +3575,18 @@
default=10000,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
-# Tuning knobs for the periodic fire-history cleanup task.
+# Tuning knobs for the periodic open-period-activity cleanup task.
# time_limit is a wall-clock budget checked *between* batches, so a single
# batch that exceeds it will still run to completion. Setting it to 0
# prevents any batches from running.
register(
- "workflow_engine.fire_history_cleanup.time_limit_seconds",
+ "workflow_engine.open_period_activity_cleanup.time_limit_seconds",
type=Float,
default=5.0,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
register(
- "workflow_engine.fire_history_cleanup.batch_size",
+ "workflow_engine.open_period_activity_cleanup.batch_size",
type=Int,
default=10000,
flags=FLAG_AUTOMATOR_MODIFIABLE,
@@ -4106,7 +4106,7 @@
# Set via deploy config (SENTRY_OPTIONS); requires restart to change.
register(
"viewer-context.enabled",
- default=False,
+ default=True,
type=Bool,
flags=FLAG_NOSTORE,
)
diff --git a/src/sentry/projects/project_rules/creator.py b/src/sentry/projects/project_rules/creator.py
index 5d3a0e435911..b34732a87e35 100644
--- a/src/sentry/projects/project_rules/creator.py
+++ b/src/sentry/projects/project_rules/creator.py
@@ -9,8 +9,8 @@
from sentry.models.project import Project
from sentry.models.rule import Rule, RuleSource
from sentry.types.actor import Actor
+from sentry.workflow_engine.defaults.detectors import ensure_default_detectors
from sentry.workflow_engine.migration_helpers.issue_alert_migration import IssueAlertMigrator
-from sentry.workflow_engine.processors.detector import ensure_default_detectors
from sentry.workflow_engine.utils.legacy_metric_tracking import report_used_legacy_models
logger = logging.getLogger(__name__)
diff --git a/src/sentry/receivers/__init__.py b/src/sentry/receivers/__init__.py
index a8c00c33aa6f..83b66df9be09 100644
--- a/src/sentry/receivers/__init__.py
+++ b/src/sentry/receivers/__init__.py
@@ -9,7 +9,6 @@
from .outbox.cell import * # noqa: F401,F403
from .outbox.control import * # noqa: F401,F403
from .owners import * # noqa: F401,F403
-from .project_detectors import * # noqa: F401,F403
from .releases import * # noqa: F401,F403
from .rule_snooze import * # noqa: F401,F403
from .rules import * # noqa: F401,F403
diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py
index fe4ba6414a57..330d276b57d6 100644
--- a/src/sentry/testutils/factories.py
+++ b/src/sentry/testutils/factories.py
@@ -561,7 +561,9 @@ def create_project(
create_default_detectors=True,
**kwargs,
) -> Project:
- from sentry.receivers.project_detectors import disable_default_detector_creation
+ from sentry.workflow_engine.receivers.project_detectors import (
+ disable_default_detector_creation,
+ )
if not kwargs.get("name"):
kwargs["name"] = petname.generate(2, " ", letters=10).title()
diff --git a/src/sentry/workflow_engine/defaults/detectors.py b/src/sentry/workflow_engine/defaults/detectors.py
new file mode 100644
index 000000000000..25d40f3c3e23
--- /dev/null
+++ b/src/sentry/workflow_engine/defaults/detectors.py
@@ -0,0 +1,286 @@
+import logging
+from collections.abc import Mapping
+from datetime import timedelta
+from functools import cache
+
+from django.db import router, transaction
+from rest_framework import status
+
+from sentry import features
+from sentry.api.exceptions import SentryAPIException
+from sentry.grouping.grouptype import ErrorGroupType
+from sentry.incidents.grouptype import MetricIssue
+from sentry.incidents.models.alert_rule import AlertRuleDetectionType
+from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE
+from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION
+from sentry.issue_detection.performance_detection import PERFORMANCE_DETECTOR_CONFIG_MAPPINGS
+from sentry.issues import grouptype
+from sentry.locks import locks
+from sentry.models.project import Project
+from sentry.projectoptions.defaults import DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS
+from sentry.seer.anomaly_detection.store_data_workflow_engine import send_new_detector_data
+from sentry.seer.anomaly_detection.types import (
+ AnomalyDetectionSeasonality,
+ AnomalyDetectionSensitivity,
+ AnomalyDetectionThresholdType,
+)
+from sentry.snuba.dataset import Dataset
+from sentry.snuba.models import SnubaQuery, SnubaQueryEventType
+from sentry.snuba.subscriptions import create_snuba_query, create_snuba_subscription
+from sentry.utils.locking import UnableToAcquireLock
+from sentry.workflow_engine.models import (
+ DataCondition,
+ DataConditionGroup,
+ DataSource,
+ DataSourceDetector,
+ Detector,
+)
+from sentry.workflow_engine.models.data_condition import Condition
+from sentry.workflow_engine.types import (
+ ERROR_DETECTOR_NAME,
+ ISSUE_STREAM_DETECTOR_NAME,
+ DetectorPriorityLevel,
+)
+from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
+
+VALID_DEFAULT_DETECTOR_TYPES = [
+ ErrorGroupType.slug,
+ IssueStreamGroupType.slug,
+ *[m.wfe_detector_type for m in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values()],
+]
+
+logger = logging.getLogger(__name__)
+
+
+@cache
+def get_disabled_platforms_by_detector_type() -> Mapping[str, frozenset[str]]:
+ """
+ Map WFE detector types to platforms where they should be disabled by default.
+ Derives from DEFAULT_DETECTOR_DISABLING_CONFIGS using the detection_enabled_key.
+ """
+ from sentry.issue_detection.detectors.disable_detectors import (
+ DEFAULT_DETECTOR_DISABLING_CONFIGS,
+ )
+
+ disabled_by_detector_type: dict[str, frozenset[str]] = {}
+
+ for disable_config in DEFAULT_DETECTOR_DISABLING_CONFIGS:
+ detector_option_key = disable_config["detector_project_option"]
+ languages_to_disable = disable_config["languages_to_disable"]
+
+ # Find matching WFE detector via detection_enabled_key
+ for mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values():
+ if mapping.detection_enabled_key == detector_option_key:
+ disabled_by_detector_type[mapping.wfe_detector_type] = frozenset(
+ languages_to_disable
+ )
+ break
+
+ return disabled_by_detector_type
+
+
+class UnableToAcquireLockApiError(SentryAPIException):
+ status_code = status.HTTP_400_BAD_REQUEST
+ code = "unable_to_acquire_lock"
+ message = "Unable to acquire lock for issue alert migration."
+
+
+def _ensure_detector(project: Project, type: str, default_enabled: bool = True) -> Detector:
+ """
+ Ensure that a detector of a given type exists for a project.
+ If the Detector doesn't already exist, we try to acquire a lock to avoid double-creating,
+ and UnableToAcquireLockApiError if that fails.
+ """
+ group_type = grouptype.registry.get_by_slug(type)
+ if not group_type:
+ raise ValueError(f"Group type {type} not registered")
+ slug = group_type.slug
+ if slug not in VALID_DEFAULT_DETECTOR_TYPES:
+ raise ValueError(f"Invalid default detector type: {slug}")
+
+ # If it already exists, life is simple and we can return immediately.
+ # If there happen to be duplicates, we prefer the oldest.
+ existing = Detector.objects.filter(type=slug, project=project).order_by("id").first()
+ if existing:
+ return existing
+
+ # If we may need to create it, we acquire a lock to avoid double-creating.
+ # There isn't a unique constraint on the detector, so we can't rely on get_or_create
+ # to avoid duplicates.
+ # However, by only locking during the one-time creation, the window for a race condition is small.
+ lock = locks.get(
+ f"workflow-engine-project-{slug}-detector:{project.id}",
+ duration=2,
+ name=f"workflow_engine_default_{slug}_detector",
+ )
+ try:
+ with (
+ # Creation should be fast, so it's worth blocking a little rather
+ # than failing a request.
+ lock.blocking_acquire(initial_delay=0.1, timeout=3),
+ transaction.atomic(router.db_for_write(Detector)),
+ ):
+ detector, _ = Detector.objects.get_or_create(
+ type=slug,
+ project=project,
+ defaults={
+ "config": {},
+ "name": (
+ ERROR_DETECTOR_NAME
+ if slug == ErrorGroupType.slug
+ else ISSUE_STREAM_DETECTOR_NAME
+ if slug == IssueStreamGroupType.slug
+ else group_type.description
+ ),
+ "enabled": default_enabled,
+ },
+ )
+ return detector
+ except UnableToAcquireLock:
+ raise UnableToAcquireLockApiError
+
+
+def ensure_default_anomaly_detector(
+ project: Project, owner_team_id: int | None = None, enabled: bool = True
+) -> Detector | None:
+ """
+ Ensure that a default anomaly detection metric monitor exists for a project.
+ If the Detector doesn't already exist, we try to acquire a lock to avoid double-creating.
+ """
+ # If it already exists, return immediately. Prefer the oldest if duplicates exist.
+ existing = (
+ Detector.objects.filter(type=MetricIssue.slug, project=project).order_by("id").first()
+ )
+ if existing:
+ logger.info(
+ "create_default_anomaly_detector.already_exists",
+ extra={"project_id": project.id, "detector_id": existing.id},
+ )
+ return existing
+
+ lock = locks.get(
+ f"workflow-engine-project-{MetricIssue.slug}-detector:{project.id}",
+ duration=2,
+ name=f"workflow_engine_default_{MetricIssue.slug}_detector",
+ )
+ try:
+ with (
+ lock.blocking_acquire(initial_delay=0.1, timeout=3),
+ transaction.atomic(router.db_for_write(Detector)),
+ ):
+ # Double-check after acquiring lock in case another process created it
+ existing = (
+ Detector.objects.filter(type=MetricIssue.slug, project=project)
+ .order_by("id")
+ .first()
+ )
+ if existing:
+ return existing
+
+ try:
+ condition_group = DataConditionGroup.objects.create(
+ logic_type=DataConditionGroup.Type.ANY,
+ organization_id=project.organization_id,
+ )
+
+ DataCondition.objects.create(
+ comparison={
+ "sensitivity": AnomalyDetectionSensitivity.LOW,
+ "seasonality": AnomalyDetectionSeasonality.AUTO,
+ "threshold_type": AnomalyDetectionThresholdType.ABOVE,
+ },
+ condition_result=DetectorPriorityLevel.HIGH,
+ type=Condition.ANOMALY_DETECTION,
+ condition_group=condition_group,
+ )
+
+ detector = Detector.objects.create(
+ project=project,
+ name="High Error Count (Default)",
+ description="Automatically monitors for anomalous spikes in error count",
+ workflow_condition_group=condition_group,
+ type=MetricIssue.slug,
+ config={
+ "detection_type": AlertRuleDetectionType.DYNAMIC.value,
+ "comparison_delta": None,
+ },
+ owner_team_id=owner_team_id,
+ enabled=enabled,
+ )
+
+ snuba_query = create_snuba_query(
+ query_type=SnubaQuery.Type.ERROR,
+ dataset=Dataset.Events,
+ query="",
+ aggregate="count()",
+ time_window=timedelta(minutes=15),
+ resolution=timedelta(minutes=15),
+ environment=None,
+ event_types=[SnubaQueryEventType.EventType.ERROR],
+ )
+
+ query_subscription = create_snuba_subscription(
+ project=project,
+ subscription_type=INCIDENTS_SNUBA_SUBSCRIPTION_TYPE,
+ snuba_query=snuba_query,
+ )
+
+ data_source = DataSource.objects.create(
+ organization_id=project.organization_id,
+ source_id=str(query_subscription.id),
+ type=DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION,
+ )
+
+ DataSourceDetector.objects.create(
+ data_source=data_source,
+ detector=detector,
+ )
+ except Exception:
+ logger.exception(
+ "create_default_anomaly_detector.create_models_failed",
+ extra={"project_id": project.id, "organization_id": project.organization_id},
+ )
+ raise
+
+ try:
+ send_new_detector_data(detector)
+ except Exception:
+ logger.exception(
+ "create_default_anomaly_detector.send_to_seer_failed",
+ extra={"project_id": project.id, "organization_id": project.organization_id},
+ )
+ raise
+
+ return detector
+ except UnableToAcquireLock:
+ raise UnableToAcquireLockApiError
+
+
+def ensure_performance_detectors(project: Project) -> dict[str, Detector]:
+ if not features.has("projects:workflow-engine-performance-detectors", project):
+ return {}
+
+ disabled_platforms_map = get_disabled_platforms_by_detector_type()
+
+ detectors = {}
+ for mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values():
+ detector_type = mapping.wfe_detector_type
+
+ # Determine initial enabled state based on platform and default settings
+ disabled_platforms = disabled_platforms_map.get(detector_type, frozenset())
+ default_enabled = DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS[
+ mapping.detection_enabled_key
+ ]
+ enabled = (project.platform not in disabled_platforms) and default_enabled
+
+ detectors[detector_type] = _ensure_detector(project, detector_type, default_enabled=enabled)
+
+ return detectors
+
+
+def ensure_default_detectors(project: Project) -> dict[str, Detector]:
+ detectors: dict[str, Detector] = {}
+ detectors[ErrorGroupType.slug] = _ensure_detector(project, ErrorGroupType.slug)
+ detectors[IssueStreamGroupType.slug] = _ensure_detector(project, IssueStreamGroupType.slug)
+ detectors.update(ensure_performance_detectors(project))
+ return detectors
diff --git a/src/sentry/workflow_engine/processors/detector.py b/src/sentry/workflow_engine/processors/detector.py
index 3e89c592b311..fcd67574b72e 100644
--- a/src/sentry/workflow_engine/processors/detector.py
+++ b/src/sentry/workflow_engine/processors/detector.py
@@ -1,300 +1,35 @@
from __future__ import annotations
import logging
-from collections.abc import Mapping
from dataclasses import dataclass
-from datetime import timedelta
-from functools import cache
import sentry_sdk
-from django.db import router, transaction
-from rest_framework import status
from sentry import features, options
-from sentry.api.exceptions import SentryAPIException
from sentry.grouping.grouptype import ErrorGroupType
from sentry.incidents.grouptype import MetricIssue
-from sentry.incidents.models.alert_rule import AlertRuleDetectionType
-from sentry.incidents.utils.constants import INCIDENTS_SNUBA_SUBSCRIPTION_TYPE
-from sentry.incidents.utils.types import DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION
-from sentry.issue_detection.performance_detection import PERFORMANCE_DETECTOR_CONFIG_MAPPINGS
-from sentry.issues import grouptype
from sentry.issues.issue_occurrence import IssueOccurrence
from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
-from sentry.locks import locks
from sentry.models.activity import Activity
from sentry.models.group import Group
-from sentry.models.project import Project
-from sentry.projectoptions.defaults import DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS
-from sentry.seer.anomaly_detection.store_data_workflow_engine import send_new_detector_data
-from sentry.seer.anomaly_detection.types import (
- AnomalyDetectionSeasonality,
- AnomalyDetectionSensitivity,
- AnomalyDetectionThresholdType,
-)
from sentry.services.eventstore.models import GroupEvent
-from sentry.snuba.dataset import Dataset
-from sentry.snuba.models import SnubaQuery, SnubaQueryEventType
-from sentry.snuba.subscriptions import create_snuba_query, create_snuba_subscription
from sentry.utils import metrics
-from sentry.utils.locking import UnableToAcquireLock
-from sentry.workflow_engine.models import DataPacket, DataSource, Detector
-from sentry.workflow_engine.models.data_condition import Condition, DataCondition
-from sentry.workflow_engine.models.data_condition_group import DataConditionGroup
-from sentry.workflow_engine.models.data_source_detector import DataSourceDetector
+
+# TODO - remove this import once getsentry can be updated
+from sentry.workflow_engine.defaults.detectors import (
+ ensure_default_detectors as ensure_default_detectors,
+)
+from sentry.workflow_engine.models import DataPacket, Detector
from sentry.workflow_engine.models.detector_group import DetectorGroup
from sentry.workflow_engine.types import (
- ERROR_DETECTOR_NAME,
- ISSUE_STREAM_DETECTOR_NAME,
DetectorEvaluationResult,
DetectorGroupKey,
- DetectorPriorityLevel,
WorkflowEventData,
)
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
logger = logging.getLogger(__name__)
-VALID_DEFAULT_DETECTOR_TYPES = [
- ErrorGroupType.slug,
- IssueStreamGroupType.slug,
- *[m.wfe_detector_type for m in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values()],
-]
-
-
-@cache
-def get_disabled_platforms_by_detector_type() -> Mapping[str, frozenset[str]]:
- """
- Map WFE detector types to platforms where they should be disabled by default.
- Derives from DEFAULT_DETECTOR_DISABLING_CONFIGS using the detection_enabled_key.
- """
- from sentry.issue_detection.detectors.disable_detectors import (
- DEFAULT_DETECTOR_DISABLING_CONFIGS,
- )
-
- disabled_by_detector_type: dict[str, frozenset[str]] = {}
-
- for disable_config in DEFAULT_DETECTOR_DISABLING_CONFIGS:
- detector_option_key = disable_config["detector_project_option"]
- languages_to_disable = disable_config["languages_to_disable"]
-
- # Find matching WFE detector via detection_enabled_key
- for mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values():
- if mapping.detection_enabled_key == detector_option_key:
- disabled_by_detector_type[mapping.wfe_detector_type] = frozenset(
- languages_to_disable
- )
- break
-
- return disabled_by_detector_type
-
-
-class UnableToAcquireLockApiError(SentryAPIException):
- status_code = status.HTTP_400_BAD_REQUEST
- code = "unable_to_acquire_lock"
- message = "Unable to acquire lock for issue alert migration."
-
-
-def _ensure_detector(project: Project, type: str, default_enabled: bool = True) -> Detector:
- """
- Ensure that a detector of a given type exists for a project.
- If the Detector doesn't already exist, we try to acquire a lock to avoid double-creating,
- and UnableToAcquireLockApiError if that fails.
- """
- group_type = grouptype.registry.get_by_slug(type)
- if not group_type:
- raise ValueError(f"Group type {type} not registered")
- slug = group_type.slug
- if slug not in VALID_DEFAULT_DETECTOR_TYPES:
- raise ValueError(f"Invalid default detector type: {slug}")
-
- # If it already exists, life is simple and we can return immediately.
- # If there happen to be duplicates, we prefer the oldest.
- existing = Detector.objects.filter(type=slug, project=project).order_by("id").first()
- if existing:
- return existing
-
- # If we may need to create it, we acquire a lock to avoid double-creating.
- # There isn't a unique constraint on the detector, so we can't rely on get_or_create
- # to avoid duplicates.
- # However, by only locking during the one-time creation, the window for a race condition is small.
- lock = locks.get(
- f"workflow-engine-project-{slug}-detector:{project.id}",
- duration=2,
- name=f"workflow_engine_default_{slug}_detector",
- )
- try:
- with (
- # Creation should be fast, so it's worth blocking a little rather
- # than failing a request.
- lock.blocking_acquire(initial_delay=0.1, timeout=3),
- transaction.atomic(router.db_for_write(Detector)),
- ):
- detector, _ = Detector.objects.get_or_create(
- type=slug,
- project=project,
- defaults={
- "config": {},
- "name": (
- ERROR_DETECTOR_NAME
- if slug == ErrorGroupType.slug
- else ISSUE_STREAM_DETECTOR_NAME
- if slug == IssueStreamGroupType.slug
- else group_type.description
- ),
- "enabled": default_enabled,
- },
- )
- return detector
- except UnableToAcquireLock:
- raise UnableToAcquireLockApiError
-
-
-def ensure_default_anomaly_detector(
- project: Project, owner_team_id: int | None = None, enabled: bool = True
-) -> Detector | None:
- """
- Ensure that a default anomaly detection metric monitor exists for a project.
- If the Detector doesn't already exist, we try to acquire a lock to avoid double-creating.
- """
- # If it already exists, return immediately. Prefer the oldest if duplicates exist.
- existing = (
- Detector.objects.filter(type=MetricIssue.slug, project=project).order_by("id").first()
- )
- if existing:
- logger.info(
- "create_default_anomaly_detector.already_exists",
- extra={"project_id": project.id, "detector_id": existing.id},
- )
- return existing
-
- lock = locks.get(
- f"workflow-engine-project-{MetricIssue.slug}-detector:{project.id}",
- duration=2,
- name=f"workflow_engine_default_{MetricIssue.slug}_detector",
- )
- try:
- with (
- lock.blocking_acquire(initial_delay=0.1, timeout=3),
- transaction.atomic(router.db_for_write(Detector)),
- ):
- # Double-check after acquiring lock in case another process created it
- existing = (
- Detector.objects.filter(type=MetricIssue.slug, project=project)
- .order_by("id")
- .first()
- )
- if existing:
- return existing
-
- try:
- condition_group = DataConditionGroup.objects.create(
- logic_type=DataConditionGroup.Type.ANY,
- organization_id=project.organization_id,
- )
-
- DataCondition.objects.create(
- comparison={
- "sensitivity": AnomalyDetectionSensitivity.LOW,
- "seasonality": AnomalyDetectionSeasonality.AUTO,
- "threshold_type": AnomalyDetectionThresholdType.ABOVE,
- },
- condition_result=DetectorPriorityLevel.HIGH,
- type=Condition.ANOMALY_DETECTION,
- condition_group=condition_group,
- )
-
- detector = Detector.objects.create(
- project=project,
- name="High Error Count (Default)",
- description="Automatically monitors for anomalous spikes in error count",
- workflow_condition_group=condition_group,
- type=MetricIssue.slug,
- config={
- "detection_type": AlertRuleDetectionType.DYNAMIC.value,
- "comparison_delta": None,
- },
- owner_team_id=owner_team_id,
- enabled=enabled,
- )
-
- snuba_query = create_snuba_query(
- query_type=SnubaQuery.Type.ERROR,
- dataset=Dataset.Events,
- query="",
- aggregate="count()",
- time_window=timedelta(minutes=15),
- resolution=timedelta(minutes=15),
- environment=None,
- event_types=[SnubaQueryEventType.EventType.ERROR],
- )
-
- query_subscription = create_snuba_subscription(
- project=project,
- subscription_type=INCIDENTS_SNUBA_SUBSCRIPTION_TYPE,
- snuba_query=snuba_query,
- )
-
- data_source = DataSource.objects.create(
- organization_id=project.organization_id,
- source_id=str(query_subscription.id),
- type=DATA_SOURCE_SNUBA_QUERY_SUBSCRIPTION,
- )
-
- DataSourceDetector.objects.create(
- data_source=data_source,
- detector=detector,
- )
- except Exception:
- logger.exception(
- "create_default_anomaly_detector.create_models_failed",
- extra={"project_id": project.id, "organization_id": project.organization_id},
- )
- raise
-
- try:
- send_new_detector_data(detector)
- except Exception:
- logger.exception(
- "create_default_anomaly_detector.send_to_seer_failed",
- extra={"project_id": project.id, "organization_id": project.organization_id},
- )
- raise
-
- return detector
- except UnableToAcquireLock:
- raise UnableToAcquireLockApiError
-
-
-def ensure_performance_detectors(project: Project) -> dict[str, Detector]:
- if not features.has("projects:workflow-engine-performance-detectors", project):
- return {}
-
- disabled_platforms_map = get_disabled_platforms_by_detector_type()
-
- detectors = {}
- for mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.values():
- detector_type = mapping.wfe_detector_type
-
- # Determine initial enabled state based on platform and default settings
- disabled_platforms = disabled_platforms_map.get(detector_type, frozenset())
- default_enabled = DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS[
- mapping.detection_enabled_key
- ]
- enabled = (project.platform not in disabled_platforms) and default_enabled
-
- detectors[detector_type] = _ensure_detector(project, detector_type, default_enabled=enabled)
-
- return detectors
-
-
-def ensure_default_detectors(project: Project) -> dict[str, Detector]:
- detectors: dict[str, Detector] = {}
- detectors[ErrorGroupType.slug] = _ensure_detector(project, ErrorGroupType.slug)
- detectors[IssueStreamGroupType.slug] = _ensure_detector(project, IssueStreamGroupType.slug)
- detectors.update(ensure_performance_detectors(project))
- return detectors
-
@dataclass(frozen=True)
class EventDetectors:
@@ -328,6 +63,7 @@ def detectors(self) -> set[Detector]:
return {d for d in [self.issue_stream_detector, self.event_detector] if d is not None}
+# TODO - Delete this once the issue stream is fully rolled out.
def _is_issue_stream_detector_enabled(event_data: WorkflowEventData) -> bool:
"""
Check if the issue stream detector should be enabled for this event's group type.
@@ -550,6 +286,7 @@ def process_detectors[T](
return results
+# TODO - move to another file / location
def associate_new_group_with_detector(group: Group, detector_id: int | None = None) -> bool:
"""
Associate a new Group with it's Detector in the database.
@@ -627,6 +364,7 @@ def associate_new_group_with_detector(group: Group, detector_id: int | None = No
return True
+# TODO - move to another file / location
def ensure_association_with_detector(group: Group, detector_id: int | None = None) -> bool:
"""
Ensure a Group has a DetectorGroup association, creating it if missing.
diff --git a/src/sentry/workflow_engine/receivers/__init__.py b/src/sentry/workflow_engine/receivers/__init__.py
index fc1f322310e9..0434ce88dd83 100644
--- a/src/sentry/workflow_engine/receivers/__init__.py
+++ b/src/sentry/workflow_engine/receivers/__init__.py
@@ -5,5 +5,6 @@
from .data_source_detector import * # NOQA
from .detector import * # NOQA
from .detector_workflow import * # NOQA
+from .project_detectors import * # noqa: F401,F403
from .workflow import * # NOQA
from .workflow_data_condition_group import * # NOQA
diff --git a/src/sentry/receivers/project_detectors.py b/src/sentry/workflow_engine/receivers/project_detectors.py
similarity index 71%
rename from src/sentry/receivers/project_detectors.py
rename to src/sentry/workflow_engine/receivers/project_detectors.py
index 0725399df098..3618abe5c429 100644
--- a/src/sentry/receivers/project_detectors.py
+++ b/src/sentry/workflow_engine/receivers/project_detectors.py
@@ -1,5 +1,6 @@
import logging
from contextlib import contextmanager
+from typing import Any, Iterator
import sentry_sdk
from django.db.models.signals import post_save
@@ -7,7 +8,8 @@
from sentry import features
from sentry.models.project import Project
from sentry.signals import project_created
-from sentry.workflow_engine.processors.detector import (
+from sentry.users.models.user import User
+from sentry.workflow_engine.defaults.detectors import (
UnableToAcquireLockApiError,
ensure_default_anomaly_detector,
ensure_default_detectors,
@@ -17,7 +19,7 @@
@contextmanager
-def disable_default_detector_creation():
+def disable_default_detector_creation() -> Iterator[None]:
"""
Context manager that temporarily disconnects the signal handlers that create
default detectors, preventing them from being created when a project is saved.
@@ -46,7 +48,11 @@ def disable_default_detector_creation():
)
-def create_project_detectors(instance, created, **kwargs):
+def create_project_detectors(
+ instance: Project,
+ created: bool,
+ **kwargs: Any,
+) -> None:
if created:
try:
ensure_default_detectors(instance)
@@ -54,7 +60,11 @@ def create_project_detectors(instance, created, **kwargs):
sentry_sdk.capture_exception(e)
-def create_default_anomaly_detector(project: Project, user=None, user_id=None, **kwargs):
+def create_default_anomaly_detector(
+ project: Project,
+ user: User | None = None,
+ **kwargs: Any,
+) -> None:
"""
Creates default anomaly detector when project is created, with the team as owner.
This listens to project_created signal which provides user information.
@@ -76,28 +86,43 @@ def create_default_anomaly_detector(project: Project, user=None, user_id=None, *
"organizations:anomaly-detection-alerts", project.organization, actor=user
)
detector = ensure_default_anomaly_detector(
- project, owner_team_id=owner_team.id if owner_team else None, enabled=enabled
+ project,
+ owner_team_id=owner_team.id if owner_team else None,
+ enabled=enabled,
)
if detector:
logger.info(
"create_default_anomaly_detector.created",
- extra={"project_id": project.id, "detector_id": detector.id, "enabled": enabled},
+ extra={
+ "project_id": project.id,
+ "detector_id": detector.id,
+ "enabled": enabled,
+ },
)
except UnableToAcquireLockApiError as e:
logger.warning(
"create_default_anomaly_detector.lock_failed",
- extra={"project_id": project.id, "organization_id": project.organization_id},
+ extra={
+ "project_id": project.id,
+ "organization_id": project.organization_id,
+ },
)
sentry_sdk.capture_exception(e)
except Exception:
logger.exception(
"create_default_anomaly_detector.failed",
- extra={"project_id": project.id, "organization_id": project.organization_id},
+ extra={
+ "project_id": project.id,
+ "organization_id": project.organization_id,
+ },
)
post_save.connect(
- create_project_detectors, sender=Project, dispatch_uid="create_project_detectors", weak=False
+ create_project_detectors,
+ sender=Project,
+ dispatch_uid="create_project_detectors",
+ weak=False,
)
project_created.connect(
create_default_anomaly_detector,
diff --git a/src/sentry/workflow_engine/tasks/__init__.py b/src/sentry/workflow_engine/tasks/__init__.py
index a7a1abaaff65..f227bc4a0860 100644
--- a/src/sentry/workflow_engine/tasks/__init__.py
+++ b/src/sentry/workflow_engine/tasks/__init__.py
@@ -2,9 +2,9 @@
"process_delayed_workflows",
"process_workflow_activity",
"process_workflows_event",
- "prune_old_fire_history",
+ "prune_old_open_period_activity",
]
-from .cleanup import prune_old_fire_history
+from .cleanup import prune_old_open_period_activity
from .delayed_workflows import process_delayed_workflows
from .workflows import process_workflow_activity, process_workflows_event
diff --git a/src/sentry/workflow_engine/tasks/cleanup.py b/src/sentry/workflow_engine/tasks/cleanup.py
index 437a4b532c59..3863ec6a9503 100644
--- a/src/sentry/workflow_engine/tasks/cleanup.py
+++ b/src/sentry/workflow_engine/tasks/cleanup.py
@@ -15,28 +15,30 @@
logger = logging.getLogger(__name__)
-FIRE_HISTORY_RETENTION_DAYS = 90
+OPEN_PERIOD_ACTIVITY_RETENTION_DAYS = 90
@instrumented_task(
- name="sentry.workflow_engine.tasks.cleanup.prune_old_fire_history",
+ name="sentry.workflow_engine.tasks.cleanup.prune_old_open_period_activity",
namespace=workflow_engine_tasks,
processing_deadline_duration=15,
silo_mode=SiloMode.CELL,
)
-def prune_old_fire_history() -> None:
- from sentry.workflow_engine.models import WorkflowFireHistory
+def prune_old_open_period_activity() -> None:
+ from sentry.models.groupopenperiodactivity import GroupOpenPeriodActivity
- time_limit: float = options.get("workflow_engine.fire_history_cleanup.time_limit_seconds")
- batch_size: int = options.get("workflow_engine.fire_history_cleanup.batch_size")
+ time_limit: float = options.get(
+ "workflow_engine.open_period_activity_cleanup.time_limit_seconds"
+ )
+ batch_size: int = options.get("workflow_engine.open_period_activity_cleanup.batch_size")
- cutoff = timezone.now() - timedelta(days=FIRE_HISTORY_RETENTION_DAYS)
+ cutoff = timezone.now() - timedelta(days=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS)
start = time.time()
batches_deleted = 0
while (time.time() - start) < time_limit:
has_more = bulk_delete_objects(
- WorkflowFireHistory,
+ GroupOpenPeriodActivity,
limit=batch_size,
logger=logger,
date_added__lte=cutoff,
@@ -46,7 +48,7 @@ def prune_old_fire_history() -> None:
batches_deleted += 1
metrics.incr(
- "workflow_engine.tasks.prune_old_fire_history.batches_deleted",
+ "workflow_engine.tasks.prune_old_open_period_activity.batches_deleted",
amount=batches_deleted,
sample_rate=1.0,
)
diff --git a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx
index 838375531c0c..74a2e864ff3b 100644
--- a/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx
+++ b/static/app/components/commandPalette/useGlobalCommandPaletteActions.tsx
@@ -6,6 +6,7 @@ import {ProjectAvatar} from '@sentry/scraps/avatar';
import {addLoadingMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {openInviteMembersModal} from 'sentry/actionCreators/modal';
+import {openSudo} from 'sentry/actionCreators/sudoModal';
import {useCommandPaletteActionsRegister} from 'sentry/components/commandPalette/context';
import type {
CMDKQueryOptions,
@@ -350,6 +351,7 @@ export function useGlobalCommandPaletteActions() {
label: t('Open _admin'),
icon: ,
},
+ keywords: [t('superuser')],
onAction: () => window.open('/_admin/', '_blank', 'noreferrer'),
},
{
@@ -357,13 +359,30 @@ export function useGlobalCommandPaletteActions() {
label: t('Open %s in _admin', organization.name),
icon: ,
},
+ keywords: [t('superuser')],
onAction: () =>
window.open(
- `/_admin/organizations/${organization.slug}/`,
+ `/_admin/customers/${organization.slug}/`,
'_blank',
'noreferrer'
),
},
+ ...(isActiveSuperuser()
+ ? []
+ : [
+ {
+ display: {
+ label: t('Open Superuser Modal'),
+ icon: ,
+ },
+ keywords: [t('superuser')],
+ onAction: () =>
+ openSudo({
+ isSuperuser: true,
+ needsReload: true,
+ }),
+ },
+ ]),
...(isActiveSuperuser()
? [
{
@@ -371,6 +390,7 @@ export function useGlobalCommandPaletteActions() {
label: t('Exit Superuser'),
icon: ,
},
+ keywords: [t('superuser')],
onAction: () => exitSuperuser(),
},
]
diff --git a/static/app/components/core/textarea/textarea.tsx b/static/app/components/core/textarea/textarea.tsx
index 7da8b105989f..32c621500af0 100644
--- a/static/app/components/core/textarea/textarea.tsx
+++ b/static/app/components/core/textarea/textarea.tsx
@@ -34,7 +34,7 @@ function TextAreaControl({
...p
}: TextAreaProps) {
return autosize ? (
-
+
) : (
);
diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx
index fd90d79f24d3..a8906a188b75 100644
--- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx
+++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.spec.tsx
@@ -68,7 +68,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization}
@@ -89,7 +88,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization}
@@ -109,7 +107,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization}
@@ -128,7 +125,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
) : (
@@ -165,7 +161,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization}
@@ -189,7 +184,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={applySeerSearchQuery}
analyticsSource="test"
- feedbackSource="test"
/>
,
{
@@ -227,7 +221,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization}
@@ -252,7 +245,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
);
@@ -267,7 +259,6 @@ describe('AskSeerComboBox', () => {
askSeerMutationOptions={askSeerMutationOptions}
applySeerSearchQuery={() => {}}
analyticsSource="test"
- feedbackSource="test"
/>
,
{organization: {...organization, hideAiFeatures: true}}
diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx
index d887de6383ec..90f2185272d1 100644
--- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx
+++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx
@@ -109,19 +109,11 @@ interface AskSeerComboBoxProps
extends Omit<
Error,
string
>;
- /**
- * The owner of the feedback form, must be an underscore-separated identifier like
- * "trace_explorer_ai_query" or "issue_list_ai_query"
- *
- * @example 'trace_explorer_ai_query'
- */
- feedbackSource: string;
initialQuery: string;
}
export function AskSeerComboBox({
initialQuery,
- feedbackSource,
analyticsSource,
...props
}: AskSeerComboBoxProps) {
@@ -173,7 +165,7 @@ export function AskSeerComboBox({
openForm({
messagePlaceholder: t('Why were these queries incorrect?'),
tags: {
- ['feedback.source']: feedbackSource,
+ ['feedback.source']: `ai_query.${analyticsArea}`,
['feedback.owner']: 'ml-ai',
['feedback.natural_language_query']: searchQuery,
['feedback.raw_result']: JSON.stringify(data?.queries).replace(/\n/g, ''),
@@ -531,7 +523,7 @@ export function AskSeerComboBox({
openForm({
messagePlaceholder: t('How can we make Seer search better for you?'),
tags: {
- ['feedback.source']: feedbackSource,
+ ['feedback.source']: `ai_query.${analyticsArea}`,
['feedback.owner']: 'ml-ai',
},
})
diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx
index d1a2e99caa5a..0bfb75c65911 100644
--- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx
+++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx
@@ -96,13 +96,6 @@ interface AskSeerPollingComboBoxProps extends Omit<
*/
analyticsSource: string;
applySeerSearchQuery: (item: T) => void;
- /**
- * The owner of the feedback form, must be an underscore-separated identifier like
- * "trace_explorer_ai_query" or "issue_list_ai_query"
- *
- * @example 'trace_explorer_ai_query'
- */
- feedbackSource: string;
initialQuery: string;
projectIds: number[];
strategy: string;
@@ -120,7 +113,6 @@ interface AskSeerPollingComboBoxProps extends Omit<
export function AskSeerPollingComboBox({
initialQuery,
- feedbackSource,
analyticsSource,
projectIds,
strategy,
@@ -194,7 +186,7 @@ export function AskSeerPollingComboBox({
openForm({
messagePlaceholder: t('Why were these queries incorrect?'),
tags: {
- ['feedback.source']: feedbackSource,
+ ['feedback.source']: `ai_query.${analyticsArea}`,
['feedback.owner']: 'ml-ai',
['feedback.natural_language_query']: searchQuery,
['feedback.raw_result']: JSON.stringify(queries).replace(/\n/g, ''),
@@ -478,7 +470,6 @@ export function AskSeerPollingComboBox({
askSeerMutationOptions={fallbackMutationOptions}
applySeerSearchQuery={props.applySeerSearchQuery}
analyticsSource={analyticsSource}
- feedbackSource={feedbackSource}
/>
);
}
@@ -575,7 +566,7 @@ export function AskSeerPollingComboBox({
openForm({
messagePlaceholder: t('How can we make Seer search better for you?'),
tags: {
- ['feedback.source']: feedbackSource,
+ ['feedback.source']: `ai_query.${analyticsArea}`,
['feedback.owner']: 'ml-ai',
},
})
diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx
index 50da5a97daea..999d7e3a6e2e 100644
--- a/static/app/components/searchQueryBuilder/index.spec.tsx
+++ b/static/app/components/searchQueryBuilder/index.spec.tsx
@@ -5159,7 +5159,6 @@ describe('SearchQueryBuilder', () => {
{}}
askSeerMutationOptions={mutationOptions({
mutationFn: async (_value: string) => {
diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx
index a4845e4ca8dd..f17ba425ae14 100644
--- a/static/app/router/routes.tsx
+++ b/static/app/router/routes.tsx
@@ -2546,10 +2546,6 @@ function buildRoutes(): RouteObject[] {
path: 'autofix/recent/',
component: make(() => import('sentry/views/issueList/pages/autofix/recentlyRun')),
},
- {
- path: 'supergroups/',
- component: make(() => import('sentry/views/issueList/pages/supergroups')),
- },
{
path: 'views/:viewId/',
component: errorHandler(OverviewWrapper),
diff --git a/static/app/views/discover/results/issueListSeerComboBox.tsx b/static/app/views/discover/results/issueListSeerComboBox.tsx
index 86a62c441bd9..0ba07a2c08d1 100644
--- a/static/app/views/discover/results/issueListSeerComboBox.tsx
+++ b/static/app/views/discover/results/issueListSeerComboBox.tsx
@@ -310,7 +310,6 @@ export function IssueListSeerComboBox({onSearch}: IssueListSeerComboBoxProps) {
applySeerSearchQuery={applySeerSearchQuery}
transformResponse={transformResponse}
analyticsSource="errors"
- feedbackSource="errors_ai_query"
fallbackMutationOptions={issueListAskSeerMutationOptions}
/>
);
diff --git a/static/app/views/explore/logs/logsTabSeerComboBox.tsx b/static/app/views/explore/logs/logsTabSeerComboBox.tsx
index 7a2b2e5d8a85..2249cf5e8cb1 100644
--- a/static/app/views/explore/logs/logsTabSeerComboBox.tsx
+++ b/static/app/views/explore/logs/logsTabSeerComboBox.tsx
@@ -322,7 +322,6 @@ export function LogsTabSeerComboBox() {
applySeerSearchQuery={applySeerSearchQuery}
transformResponse={transformResponse}
analyticsSource="logs"
- feedbackSource="logs_ai_query"
fallbackMutationOptions={logsTabAskSeerMutationOptions}
/>
);
diff --git a/static/app/views/explore/spans/spansTabSeerComboBox.tsx b/static/app/views/explore/spans/spansTabSeerComboBox.tsx
index 071050fbfe5e..f26bc20e891d 100644
--- a/static/app/views/explore/spans/spansTabSeerComboBox.tsx
+++ b/static/app/views/explore/spans/spansTabSeerComboBox.tsx
@@ -352,7 +352,6 @@ export function SpansTabSeerComboBox() {
applySeerSearchQuery={applySeerSearchQuery}
transformResponse={transformResponse}
analyticsSource="trace.explorer"
- feedbackSource="trace_explorer_ai_query"
fallbackMutationOptions={spansTabAskSeerMutationOptions}
/>
);
@@ -364,7 +363,6 @@ export function SpansTabSeerComboBox() {
askSeerMutationOptions={spansTabAskSeerMutationOptions}
applySeerSearchQuery={applySeerSearchQuery}
analyticsSource="trace.explorer"
- feedbackSource="trace_explorer_ai_query"
/>
);
}
diff --git a/static/app/views/integrationOrganizationLink/index.tsx b/static/app/views/integrationOrganizationLink/index.tsx
index e44f08516159..55620a2a68f0 100644
--- a/static/app/views/integrationOrganizationLink/index.tsx
+++ b/static/app/views/integrationOrganizationLink/index.tsx
@@ -31,7 +31,7 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useLocation} from 'sentry/utils/useLocation';
import {useParams} from 'sentry/utils/useParams';
import RouteError from 'sentry/views/routeError';
-import {AddIntegration} from 'sentry/views/settings/organizationIntegrations/addIntegration';
+import {useAddIntegration} from 'sentry/views/settings/organizationIntegrations/addIntegration';
import {IntegrationLayout} from 'sentry/views/settings/organizationIntegrations/detailedView/integrationLayout';
interface GitHubIntegrationInstallation {
@@ -228,37 +228,19 @@ export default function IntegrationOrganizationLink() {
const {IntegrationFeatures} = getIntegrationFeatureGate();
- // Github uses a different installation flow with the installationId as a parameter
- // We have to wrap our installation button with AddIntegration so we can get the
- // addIntegrationWithInstallationId callback.
- // if we don't have an installationId, we need to use the finishInstallation callback.
return (
{({disabled, disabledReason}) => (
-
- {addIntegrationWithInstallationId => (
-
-
- {disabled && }
-
- )}
-
+ onInstall={onInstallWithInstallationId}
+ installationId={installationId}
+ hasAccess={hasAccess}
+ disabled={disabled}
+ disabledReason={disabledReason}
+ finishInstallation={finishInstallation}
+ />
)}
);
@@ -420,6 +402,45 @@ export default function IntegrationOrganizationLink() {
);
}
+function AddIntegrationButton({
+ provider,
+ organization,
+ onInstall,
+ installationId,
+ hasAccess,
+ disabled,
+ disabledReason,
+ finishInstallation,
+}: {
+ disabled: boolean;
+ disabledReason: React.ReactNode;
+ finishInstallation: () => void;
+ hasAccess: boolean | undefined;
+ onInstall: (data: Integration) => void;
+ organization: Organization;
+ provider: IntegrationProvider;
+ installationId?: string;
+}) {
+ const {startFlow} = useAddIntegration({provider, organization, onInstall});
+
+ return (
+
+
+ {disabled && }
+
+ );
+}
+
const InstallLink = styled('pre')`
margin-bottom: 0;
background: #fbe3e1;
diff --git a/static/app/views/issueList/issueListSeerComboBox.tsx b/static/app/views/issueList/issueListSeerComboBox.tsx
index 5bfd6e5cd15c..276946e632da 100644
--- a/static/app/views/issueList/issueListSeerComboBox.tsx
+++ b/static/app/views/issueList/issueListSeerComboBox.tsx
@@ -255,7 +255,6 @@ export function IssueListSeerComboBox() {
applySeerSearchQuery={applySeerSearchQuery}
transformResponse={transformResponse}
analyticsSource="issue.list"
- feedbackSource="issue_list_ai_query"
fallbackMutationOptions={issueListAskSeerMutationOptions}
/>
);
diff --git a/static/app/views/issueList/pages/supergroups.tsx b/static/app/views/issueList/pages/supergroups.tsx
deleted file mode 100644
index cbdd890146f4..000000000000
--- a/static/app/views/issueList/pages/supergroups.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import styled from '@emotion/styled';
-
-import {FeatureBadge} from '@sentry/scraps/badge';
-import {inlineCodeStyles} from '@sentry/scraps/code';
-import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout';
-import {Heading, Text} from '@sentry/scraps/text';
-
-import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton';
-import {useDrawer} from 'sentry/components/globalDrawer';
-import * as Layout from 'sentry/components/layouts/thirds';
-import {LoadingError} from 'sentry/components/loadingError';
-import {LoadingIndicator} from 'sentry/components/loadingIndicator';
-import {Redirect} from 'sentry/components/redirect';
-import {IconFocus} from 'sentry/icons';
-import {t, tn} from 'sentry/locale';
-import {getApiUrl} from 'sentry/utils/api/getApiUrl';
-import {MarkedText} from 'sentry/utils/marked/markedText';
-import {useApiQuery} from 'sentry/utils/queryClient';
-import {useOrganization} from 'sentry/utils/useOrganization';
-import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergroupDrawer';
-import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types';
-
-interface ListSupergroupsResponse {
- data: SupergroupDetail[];
-}
-
-function SupergroupCard({
- supergroup,
- onClick,
-}: {
- onClick: () => void;
- supergroup: SupergroupDetail;
-}) {
- return (
-
-
-
- {supergroup.title}
-
-
-
-
- {tn('%s issue', '%s issues', supergroup.group_ids.length)}
-
- {supergroup.error_type && (
-
-
- {t('Error:')}
-
-
- {supergroup.error_type}
-
-
- )}
- {supergroup.code_area && (
-
-
- {t('Location:')}
-
-
- {supergroup.code_area}
-
-
- )}
-
-
- {supergroup.summary && (
-
-
-
-
-
- {t('Root Cause')}
-
-
-
-
-
-
-
- )}
-
-
- );
-}
-
-function Supergroups() {
- const organization = useOrganization();
- const hasTopIssuesUI = organization.features.includes('top-issues-ui');
- const {openDrawer} = useDrawer();
-
- const {
- data: response,
- isPending,
- isError,
- refetch,
- } = useApiQuery(
- [
- getApiUrl('/organizations/$organizationIdOrSlug/seer/supergroups/', {
- path: {organizationIdOrSlug: organization.slug},
- }),
- ],
- {
- staleTime: 60000,
- enabled: hasTopIssuesUI,
- }
- );
-
- const supergroups = (response?.data ?? []).filter(sg => sg.group_ids.length > 1);
-
- const handleSupergroupClick = (supergroup: SupergroupDetail) => {
- openDrawer(
- () => ,
- {
- ariaLabel: t('Supergroup details'),
- drawerKey: 'supergroup-drawer',
- }
- );
- };
-
- if (!hasTopIssuesUI) {
- return ;
- }
-
- return (
-
-
-
-
- {t('Supergroups')}
-
-
-
-
-
-
-
-
-
-
- {isPending ? (
-
- ) : isError ? (
-
- ) : supergroups.length === 0 ? (
-
-
- {t('No supergroups found')}
-
-
- ) : (
-
-
- {tn('%s supergroup', '%s supergroups', supergroups.length)}
-
-
- {supergroups.map(sg => (
- handleSupergroupClick(sg)}
- />
- ))}
-
-
- )}
-
-
-
- );
-}
-
-const CardContainer = styled(Container)`
- position: relative;
- overflow: hidden;
- cursor: pointer;
- transition:
- background-color 0.2s ease,
- border-color 0.2s ease,
- box-shadow 0.2s ease;
-
- &:hover {
- background: ${p => p.theme.tokens.background.secondary};
- border-color: ${p => p.theme.tokens.border.accent.moderate};
- box-shadow: ${p => p.theme.dropShadowMedium};
- }
-`;
-
-export const StyledMarkedText = styled(MarkedText)`
- code:not(pre code) {
- ${p => inlineCodeStyles(p.theme)};
- }
-`;
-
-export default Supergroups;
diff --git a/static/app/views/issueList/supergroups/supergroupDrawer.tsx b/static/app/views/issueList/supergroups/supergroupDrawer.tsx
index 6db963cbdd62..45028396f09d 100644
--- a/static/app/views/issueList/supergroups/supergroupDrawer.tsx
+++ b/static/app/views/issueList/supergroups/supergroupDrawer.tsx
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import {Badge} from '@sentry/scraps/badge';
import {Button} from '@sentry/scraps/button';
+import {inlineCodeStyles} from '@sentry/scraps/code';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {Heading, Text} from '@sentry/scraps/text';
@@ -29,9 +30,9 @@ import {t} from 'sentry/locale';
import {GroupStore} from 'sentry/stores/groupStore';
import type {Group} from 'sentry/types/group';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
+import {MarkedText} from 'sentry/utils/marked/markedText';
import {useApiQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
-import {StyledMarkedText} from 'sentry/views/issueList/pages/supergroups';
import {SupergroupFeedback} from 'sentry/views/issueList/supergroups/supergroupFeedback';
import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types';
@@ -306,3 +307,9 @@ const HighlightableRow = styled('div')<{highlighted: boolean}>`
border-left: 3px solid ${p.theme.tokens.border.accent.vibrant};
`}
`;
+
+const StyledMarkedText = styled(MarkedText)`
+ code:not(pre code) {
+ ${p => inlineCodeStyles(p.theme)};
+ }
+`;
diff --git a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
index 0922df43bc23..1705e53df9a7 100644
--- a/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
+++ b/static/app/views/navigation/secondary/sections/issues/issuesSecondaryNavigation.tsx
@@ -13,8 +13,6 @@ import {IssueViews} from 'sentry/views/navigation/secondary/sections/issues/issu
export function IssuesSecondaryNavigation() {
const organization = useOrganization();
const baseUrl = `/organizations/${organization.slug}/issues`;
- const hasTopIssuesUI = organization.features.includes('top-issues-ui');
-
return (
{t('Issues')}
@@ -30,16 +28,6 @@ export function IssuesSecondaryNavigation() {
{t('Feed')}
- {hasTopIssuesUI && (
-
-
- {t('Supergroups')}
-
-
- )}
diff --git a/static/app/views/organizationStats/utils.spec.tsx b/static/app/views/organizationStats/utils.spec.tsx
index 8c4685eeb1fb..0f4c4ee19957 100644
--- a/static/app/views/organizationStats/utils.spec.tsx
+++ b/static/app/views/organizationStats/utils.spec.tsx
@@ -79,74 +79,44 @@ describe('formatUsageWithUnits', () => {
).toBe('1.23B');
});
- it('returns correct strings for Attachments', () => {
- expect(formatUsageWithUnits(0, DATA_CATEGORY_INFO.attachment.plural)).toBe('0 GB');
- expect(formatUsageWithUnits(MILLION, DATA_CATEGORY_INFO.attachment.plural)).toBe(
+ it.each([
+ ['Attachments', DATA_CATEGORY_INFO.attachment.plural],
+ ['Log Bytes', DATA_CATEGORY_INFO.log_byte.plural],
+ ['Trace Metric Bytes', DATA_CATEGORY_INFO.trace_metric_byte.plural],
+ ])('returns correct byte formatting for %s', (_label, dataCategory) => {
+ expect(formatUsageWithUnits(0, dataCategory)).toBe('0 GB');
+ expect(formatUsageWithUnits(MILLION, dataCategory)).toBe('0 GB');
+ expect(formatUsageWithUnits(BILLION, dataCategory)).toBe('1 GB');
+ expect(formatUsageWithUnits(1.234 * BILLION, dataCategory)).toBe('1.23 GB');
+ expect(formatUsageWithUnits(1234 * GIGABYTE, dataCategory)).toBe('1,234 GB');
+
+ expect(formatUsageWithUnits(0, dataCategory, {isAbbreviated: true})).toBe('0 GB');
+ expect(formatUsageWithUnits(MILLION, dataCategory, {isAbbreviated: true})).toBe(
'0 GB'
);
- expect(formatUsageWithUnits(BILLION, DATA_CATEGORY_INFO.attachment.plural)).toBe(
+ expect(formatUsageWithUnits(BILLION, dataCategory, {isAbbreviated: true})).toBe(
'1 GB'
);
expect(
- formatUsageWithUnits(1.234 * BILLION, DATA_CATEGORY_INFO.attachment.plural)
- ).toBe('1.23 GB');
- expect(
- formatUsageWithUnits(1234 * GIGABYTE, DATA_CATEGORY_INFO.attachment.plural)
- ).toBe('1,234 GB');
-
- expect(
- formatUsageWithUnits(0, DATA_CATEGORY_INFO.attachment.plural, {isAbbreviated: true})
- ).toBe('0 GB');
- expect(
- formatUsageWithUnits(MILLION, DATA_CATEGORY_INFO.attachment.plural, {
- isAbbreviated: true,
- })
- ).toBe('0 GB');
- expect(
- formatUsageWithUnits(BILLION, DATA_CATEGORY_INFO.attachment.plural, {
- isAbbreviated: true,
- })
+ formatUsageWithUnits(1.234 * BILLION, dataCategory, {isAbbreviated: true})
).toBe('1 GB');
expect(
- formatUsageWithUnits(1.234 * BILLION, DATA_CATEGORY_INFO.attachment.plural, {
- isAbbreviated: true,
- })
- ).toBe('1 GB');
- expect(
- formatUsageWithUnits(1234 * BILLION, DATA_CATEGORY_INFO.attachment.plural, {
- isAbbreviated: true,
- })
+ formatUsageWithUnits(1234 * BILLION, dataCategory, {isAbbreviated: true})
).toBe('1.2K GB');
+ expect(formatUsageWithUnits(0, dataCategory, {useUnitScaling: true})).toBe('0 B');
+ expect(formatUsageWithUnits(1000, dataCategory, {useUnitScaling: true})).toBe('1 KB');
+ expect(formatUsageWithUnits(MILLION, dataCategory, {useUnitScaling: true})).toBe(
+ '1 MB'
+ );
expect(
- formatUsageWithUnits(0, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
- ).toBe('0 B');
- expect(
- formatUsageWithUnits(1000, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
- ).toBe('1 KB');
- expect(
- formatUsageWithUnits(MILLION, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
- ).toBe('1 MB');
- expect(
- formatUsageWithUnits(1.234 * MILLION, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
+ formatUsageWithUnits(1.234 * MILLION, dataCategory, {useUnitScaling: true})
).toBe('1.23 MB');
expect(
- formatUsageWithUnits(1.234 * BILLION, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
+ formatUsageWithUnits(1.234 * BILLION, dataCategory, {useUnitScaling: true})
).toBe('1.23 GB');
expect(
- formatUsageWithUnits(1234 * BILLION, DATA_CATEGORY_INFO.attachment.plural, {
- useUnitScaling: true,
- })
+ formatUsageWithUnits(1234 * BILLION, dataCategory, {useUnitScaling: true})
).toBe('1.23 TB');
});
diff --git a/static/app/views/settings/organizationIntegrations/addIntegration.spec.tsx b/static/app/views/settings/organizationIntegrations/addIntegration.spec.tsx
index ba49ec49cd14..e738bde669b4 100644
--- a/static/app/views/settings/organizationIntegrations/addIntegration.spec.tsx
+++ b/static/app/views/settings/organizationIntegrations/addIntegration.spec.tsx
@@ -2,86 +2,315 @@ import {GitHubIntegrationFixture} from 'sentry-fixture/githubIntegration';
import {GitHubIntegrationProviderFixture} from 'sentry-fixture/githubIntegrationProvider';
import {OrganizationFixture} from 'sentry-fixture/organization';
-import {render, waitFor} from 'sentry-test/reactTestingLibrary';
-import {setWindowLocation} from 'sentry-test/utils';
+import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
+import * as indicators from 'sentry/actionCreators/indicator';
+import * as pipelineModal from 'sentry/components/pipeline/modal';
import {ConfigStore} from 'sentry/stores/configStore';
import type {Config} from 'sentry/types/system';
-import {AddIntegration} from 'sentry/views/settings/organizationIntegrations/addIntegration';
+import {useAddIntegration} from 'sentry/views/settings/organizationIntegrations/addIntegration';
-describe('AddIntegration', () => {
+describe('useAddIntegration', () => {
const provider = GitHubIntegrationProviderFixture();
const integration = GitHubIntegrationFixture();
let configState: Config;
- function interceptMessageEvent(event: MessageEvent) {
- if (event.origin === '') {
- event.stopImmediatePropagation();
- const eventWithOrigin = new MessageEvent('message', {
- data: event.data,
- origin: 'https://foobar.sentry.io',
- });
- window.dispatchEvent(eventWithOrigin);
- }
- }
-
beforeEach(() => {
configState = ConfigStore.getState();
ConfigStore.loadInitialData({
...configState,
- customerDomain: {
- subdomain: 'foobar',
- organizationUrl: 'https://foobar.sentry.io',
- sentryUrl: 'https://sentry.io',
- },
links: {
- organizationUrl: 'https://foobar.sentry.io',
+ organizationUrl: document.location.origin,
regionUrl: 'https://us.sentry.io',
sentryUrl: 'https://sentry.io',
},
});
-
- setWindowLocation('https://foobar.sentry.io');
- window.addEventListener('message', interceptMessageEvent);
});
afterEach(() => {
- window.removeEventListener('message', interceptMessageEvent);
ConfigStore.loadInitialData(configState);
+ jest.restoreAllMocks();
});
- it('Adds an integration on dialog completion', async () => {
- const onAdd = jest.fn();
-
- const focus = jest.fn();
- const open = jest.fn().mockReturnValue({focus});
- global.open = open;
-
- render(
-
- {openDialog => (
- openDialog()}>
- Click
-
- )}
-
- );
-
- const newIntegration = {
- success: true,
- data: Object.assign({}, integration, {
- id: '2',
- domain_name: 'new-integration.github.com',
- icon: 'http://example.com/new-integration-icon.png',
- name: 'New Integration',
- }),
- };
-
- window.postMessage(newIntegration, '*');
- await waitFor(() => expect(onAdd).toHaveBeenCalledWith(newIntegration.data));
+ /**
+ * Dispatches a MessageEvent that appears to come from the mock popup window,
+ * matching the origin and source checks in the hook's message handler.
+ */
+ function postMessageFromPopup(popup: Window, data: unknown) {
+ const event = new MessageEvent('message', {
+ data,
+ origin: document.location.origin,
+ });
+ Object.defineProperty(event, 'source', {value: popup});
+ window.dispatchEvent(event);
+ }
+
+ describe('legacy flow', () => {
+ let popup: Window;
+
+ beforeEach(() => {
+ popup = {focus: jest.fn(), close: jest.fn()} as unknown as Window;
+ jest.spyOn(window, 'open').mockReturnValue(popup);
+ });
+
+ it('opens a popup window when startFlow is called', () => {
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ expect(window.open).toHaveBeenCalledTimes(1);
+ expect(jest.mocked(window.open).mock.calls[0]![0]).toBe(
+ '/github-integration-setup-uri/?'
+ );
+ expect(popup.focus).toHaveBeenCalledTimes(1);
+ });
+
+ it('includes account and modalParams in the popup URL', () => {
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ account: 'my-account',
+ modalParams: {use_staging: '1'},
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ const calls = jest.mocked(window.open).mock.calls[0]!;
+ const url = calls[0] as string;
+ expect(url).toContain('account=my-account');
+ expect(url).toContain('use_staging=1');
+ expect(calls[1]).toBe('sentryAddStagingIntegration');
+ });
+
+ it('includes urlParams passed to startFlow', () => {
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow({custom_param: 'value'}));
+
+ const url = jest.mocked(window.open).mock.calls[0]![0] as string;
+ expect(url).toContain('custom_param=value');
+ });
+
+ it('calls onInstall when a success message is received', async () => {
+ const onInstall = jest.fn();
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall,
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ const newIntegration = {
+ success: true,
+ data: {
+ ...integration,
+ id: '2',
+ domain_name: 'new-integration.github.com',
+ icon: 'http://example.com/new-integration-icon.png',
+ name: 'New Integration',
+ },
+ };
+
+ postMessageFromPopup(popup, newIntegration);
+ await waitFor(() => expect(onInstall).toHaveBeenCalledWith(newIntegration.data));
+ });
+
+ it('shows a success indicator on successful installation', async () => {
+ const successSpy = jest.spyOn(indicators, 'addSuccessMessage');
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ postMessageFromPopup(popup, {success: true, data: integration});
+ await waitFor(() => expect(successSpy).toHaveBeenCalledWith('GitHub added'));
+ });
+
+ it('shows an error indicator when the message has success: false', async () => {
+ const errorSpy = jest.spyOn(indicators, 'addErrorMessage');
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ postMessageFromPopup(popup, {success: false, data: {error: 'OAuth failed'}});
+ await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('OAuth failed'));
+ });
+
+ it('shows a generic error when no error message is provided', async () => {
+ const errorSpy = jest.spyOn(indicators, 'addErrorMessage');
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ postMessageFromPopup(popup, {success: false, data: {}});
+ await waitFor(() =>
+ expect(errorSpy).toHaveBeenCalledWith('An unknown error occurred')
+ );
+ });
+
+ it('ignores messages from invalid origins', async () => {
+ const onInstall = jest.fn();
+
+ renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall,
+ })
+ );
+
+ // jsdom's postMessage uses origin '' which won't match any valid origin
+ window.postMessage({success: true, data: integration}, '*');
+
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ });
+ expect(onInstall).not.toHaveBeenCalled();
+ });
+
+ it('does not call onInstall when data is empty on success', async () => {
+ const onInstall = jest.fn();
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall,
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ postMessageFromPopup(popup, {success: true, data: null});
+
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ });
+ expect(onInstall).not.toHaveBeenCalled();
+ });
+
+ it('closes the dialog on unmount', () => {
+ const {result, unmount} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization: OrganizationFixture(),
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+ unmount();
+
+ expect(popup.close).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('API pipeline flow', () => {
+ it('opens the pipeline modal when feature flag is enabled', () => {
+ const openPipelineModalSpy = jest.spyOn(pipelineModal, 'openPipelineModal');
+ const onInstall = jest.fn();
+
+ const organization = OrganizationFixture({
+ features: ['integration-api-pipeline-github'],
+ });
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization,
+ onInstall,
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ expect(openPipelineModalSpy).toHaveBeenCalledWith({
+ type: 'integration',
+ provider: 'github',
+ onComplete: expect.any(Function),
+ });
+ });
+
+ it('does not open a popup window when the pipeline modal is used', () => {
+ jest.spyOn(pipelineModal, 'openPipelineModal');
+ jest.spyOn(window, 'open');
+
+ const organization = OrganizationFixture({
+ features: ['integration-api-pipeline-github'],
+ });
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization,
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
+ it('falls back to legacy flow when feature flag is not enabled', () => {
+ const openPipelineModalSpy = jest.spyOn(pipelineModal, 'openPipelineModal');
+ jest
+ .spyOn(window, 'open')
+ .mockReturnValue({focus: jest.fn(), close: jest.fn()} as unknown as Window);
+
+ const organization = OrganizationFixture({features: []});
+
+ const {result} = renderHookWithProviders(() =>
+ useAddIntegration({
+ provider,
+ organization,
+ onInstall: jest.fn(),
+ })
+ );
+
+ act(() => result.current.startFlow());
+
+ expect(openPipelineModalSpy).not.toHaveBeenCalled();
+ expect(window.open).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/static/app/views/settings/organizationIntegrations/addIntegration.tsx b/static/app/views/settings/organizationIntegrations/addIntegration.tsx
index 7f632f732bfb..4f1664fc4d5a 100644
--- a/static/app/views/settings/organizationIntegrations/addIntegration.tsx
+++ b/static/app/views/settings/organizationIntegrations/addIntegration.tsx
@@ -1,7 +1,9 @@
-import {Component} from 'react';
+import {useCallback, useEffect, useRef} from 'react';
import * as qs from 'query-string';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {openPipelineModal} from 'sentry/components/pipeline/modal';
+import type {ProvidersByType} from 'sentry/components/pipeline/registry';
import {t} from 'sentry/locale';
import {ConfigStore} from 'sentry/stores/configStore';
import type {IntegrationProvider, IntegrationWithConfig} from 'sentry/types/integrations';
@@ -9,12 +11,11 @@ import type {Organization} from 'sentry/types/organization';
import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
import type {MessagingIntegrationAnalyticsView} from 'sentry/views/alerts/rules/issue/setupMessagingIntegrationButton';
-type Props = {
- children: (openDialog: (urlParams?: Record) => void) => React.ReactNode;
+export interface AddIntegrationParams {
onInstall: (data: IntegrationWithConfig) => void;
organization: Organization;
provider: IntegrationProvider;
- account?: string | null; // for analytics
+ account?: string | null;
analyticsParams?: {
already_installed: boolean;
view:
@@ -29,114 +30,206 @@ type Props = {
| 'test_analytics_org_selector';
};
modalParams?: Record;
-};
-
-export class AddIntegration extends Component {
- componentDidMount() {
- window.addEventListener('message', this.didReceiveMessage);
- }
+}
- componentWillUnmount() {
- window.removeEventListener('message', this.didReceiveMessage);
- this.dialog?.close();
+/**
+ * Per-provider feature flags that gate the new API-driven pipeline setup flow.
+ * When enabled for a provider, the integration setup uses the React pipeline
+ * modal instead of the legacy Django view popup window.
+ *
+ * Keys are provider identifiers (constrained to registered pipeline providers
+ * via `satisfies`), values are feature flag names without the `organizations:`
+ * prefix.
+ */
+const API_PIPELINE_FEATURE_FLAGS = {
+ github: 'integration-api-pipeline-github',
+} as const satisfies Partial>;
+
+type ApiPipelineProvider = keyof typeof API_PIPELINE_FEATURE_FLAGS;
+
+function getApiPipelineProvider(
+ organization: Organization,
+ providerKey: string
+): ApiPipelineProvider | null {
+ if (!(providerKey in API_PIPELINE_FEATURE_FLAGS)) {
+ return null;
}
-
- dialog: Window | null = null;
-
- computeCenteredWindow(width: number, height: number) {
- // Taken from: https://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen
- const screenLeft =
- window.screenLeft === undefined ? window.screenX : window.screenLeft;
-
- const screenTop = window.screenTop === undefined ? window.screenY : window.screenTop;
-
- const innerWidth = window.innerWidth
- ? window.innerWidth
- : document.documentElement.clientWidth
- ? document.documentElement.clientWidth
- : screen.width;
-
- const innerHeight = window.innerHeight
- ? window.innerHeight
- : document.documentElement.clientHeight
- ? document.documentElement.clientHeight
- : screen.height;
-
- const left = innerWidth / 2 - width / 2 + screenLeft;
- const top = innerHeight / 2 - height / 2 + screenTop;
-
- return {left, top};
+ const key = providerKey as ApiPipelineProvider;
+ const flag = API_PIPELINE_FEATURE_FLAGS[key];
+ if (!organization.features.includes(flag)) {
+ return null;
}
+ return key;
+}
- openDialog = (urlParams?: Record) => {
- const {account, analyticsParams, modalParams, organization, provider} = this.props;
-
- trackIntegrationAnalytics('integrations.installation_start', {
- integration: provider.key,
- integration_type: 'first_party',
- organization,
- ...analyticsParams,
- });
- const name = 'sentryAddIntegration';
- const {url, width, height} = provider.setupDialog;
- const {left, top} = this.computeCenteredWindow(width, height);
-
- let query: Record = {...urlParams};
-
- if (account) {
- query.account = account;
- }
+function computeCenteredWindow(width: number, height: number) {
+ const screenLeft = window.screenLeft === undefined ? window.screenX : window.screenLeft;
+ const screenTop = window.screenTop === undefined ? window.screenY : window.screenTop;
- if (modalParams) {
- query = {...query, ...modalParams};
- }
+ const innerWidth = window.innerWidth
+ ? window.innerWidth
+ : document.documentElement.clientWidth
+ ? document.documentElement.clientWidth
+ : screen.width;
- const installUrl = `${url}?${qs.stringify(query)}`;
- const opts = `scrollbars=yes,width=${width},height=${height},top=${top},left=${left}`;
+ const innerHeight = window.innerHeight
+ ? window.innerHeight
+ : document.documentElement.clientHeight
+ ? document.documentElement.clientHeight
+ : screen.height;
- this.dialog = window.open(installUrl, name, opts);
- this.dialog?.focus();
- };
+ const left = innerWidth / 2 - width / 2 + screenLeft;
+ const top = innerHeight / 2 - height / 2 + screenTop;
- didReceiveMessage = (message: MessageEvent) => {
- const {analyticsParams, onInstall, organization, provider} = this.props;
- const validOrigins = [
- ConfigStore.get('links').sentryUrl,
- ConfigStore.get('links').organizationUrl,
- document.location.origin,
- ];
- if (!validOrigins.includes(message.origin)) {
- return;
- }
+ return {left, top};
+}
- if (message.source !== this.dialog) {
- return;
+/**
+ * Opens the legacy Django-driven integration setup flow in a popup window and
+ * listens for a `postMessage` callback on completion.
+ *
+ * Used for integrations that have not been migrated to the API pipeline system.
+ */
+function useLegacyAddIntegration({
+ provider,
+ organization,
+ onInstall,
+ account,
+ analyticsParams,
+ modalParams,
+}: AddIntegrationParams) {
+ const dialogRef = useRef(null);
+ const onInstallRef = useRef(onInstall);
+ onInstallRef.current = onInstall;
+ const analyticsParamsRef = useRef(analyticsParams);
+ analyticsParamsRef.current = analyticsParams;
+
+ useEffect(() => {
+ function handleMessage(message: MessageEvent) {
+ const validOrigins = [
+ ConfigStore.get('links').sentryUrl,
+ ConfigStore.get('links').organizationUrl,
+ document.location.origin,
+ ];
+ if (!validOrigins.includes(message.origin)) {
+ return;
+ }
+ if (message.source !== dialogRef.current) {
+ return;
+ }
+
+ const {success, data} = message.data;
+ dialogRef.current = null;
+
+ if (!success) {
+ addErrorMessage(data?.error ?? t('An unknown error occurred'));
+ return;
+ }
+ if (!data) {
+ return;
+ }
+
+ trackIntegrationAnalytics('integrations.installation_complete', {
+ integration: provider.key,
+ integration_type: 'first_party',
+ organization,
+ ...analyticsParamsRef.current,
+ });
+ addSuccessMessage(t('%s added', provider.name));
+ onInstallRef.current(data);
}
- const {success, data} = message.data;
- this.dialog = null;
-
- if (!success) {
- addErrorMessage(data?.error ?? t('An unknown error occurred'));
- return;
- }
+ window.addEventListener('message', handleMessage);
+ return () => {
+ window.removeEventListener('message', handleMessage);
+ dialogRef.current?.close();
+ };
+ }, [provider.key, provider.name, organization]);
+
+ const startFlow = useCallback(
+ (urlParams?: Record) => {
+ trackIntegrationAnalytics('integrations.installation_start', {
+ integration: provider.key,
+ integration_type: 'first_party',
+ organization,
+ ...analyticsParams,
+ });
+
+ const name = modalParams?.use_staging
+ ? 'sentryAddStagingIntegration'
+ : 'sentryAddIntegration';
+ const {url, width, height} = provider.setupDialog;
+ const {left, top} = computeCenteredWindow(width, height);
+
+ let query: Record = {...urlParams};
+ if (account) {
+ query.account = account;
+ }
+ if (modalParams) {
+ query = {...query, ...modalParams};
+ }
+
+ const installUrl = `${url}?${qs.stringify(query)}`;
+ const opts = `scrollbars=yes,width=${width},height=${height},top=${top},left=${left}`;
+
+ dialogRef.current = window.open(installUrl, name, opts);
+ dialogRef.current?.focus();
+ },
+ [provider, organization, account, analyticsParams, modalParams]
+ );
+
+ return {startFlow};
+}
- if (!data) {
- return;
- }
- trackIntegrationAnalytics('integrations.installation_complete', {
- integration: provider.key,
- integration_type: 'first_party',
+/**
+ * Opens the integration setup flow. Automatically selects between the new
+ * API-driven pipeline modal and the legacy popup-based flow depending on
+ * the organization's feature flags.
+ */
+export function useAddIntegration(params: AddIntegrationParams) {
+ const {provider, organization, onInstall} = params;
+ const {startFlow: legacyStartFlow} = useLegacyAddIntegration(params);
+ const pipelineProvider = getApiPipelineProvider(organization, provider.key);
+
+ const startFlow = useCallback(
+ (urlParams?: Record) => {
+ // Fallback to legacy view-based flow when the feature flag for API based
+ // flows is not enabled for the provider.
+ if (pipelineProvider === null) {
+ legacyStartFlow(urlParams);
+ return;
+ }
+
+ trackIntegrationAnalytics('integrations.installation_start', {
+ integration: provider.key,
+ integration_type: 'first_party',
+ organization,
+ ...params.analyticsParams,
+ });
+ openPipelineModal({
+ type: 'integration',
+ provider: pipelineProvider,
+ onComplete: (data: IntegrationWithConfig) => {
+ trackIntegrationAnalytics('integrations.installation_complete', {
+ integration: provider.key,
+ integration_type: 'first_party',
+ organization,
+ ...params.analyticsParams,
+ });
+ addSuccessMessage(t('%s added', provider.name));
+ onInstall(data);
+ },
+ });
+ },
+ [
+ pipelineProvider,
+ provider,
organization,
- ...analyticsParams,
- });
- addSuccessMessage(t('%s added', provider.name));
- onInstall(data);
- };
-
- render() {
- const {children} = this.props;
+ params.analyticsParams,
+ onInstall,
+ legacyStartFlow,
+ ]
+ );
- return children(this.openDialog);
- }
+ return {startFlow};
}
diff --git a/static/app/views/settings/organizationIntegrations/addIntegrationButton.tsx b/static/app/views/settings/organizationIntegrations/addIntegrationButton.tsx
index ed997df4cc28..739701187e05 100644
--- a/static/app/views/settings/organizationIntegrations/addIntegrationButton.tsx
+++ b/static/app/views/settings/organizationIntegrations/addIntegrationButton.tsx
@@ -6,13 +6,14 @@ import {t} from 'sentry/locale';
import type {IntegrationWithConfig} from 'sentry/types/integrations';
import {trackAnalytics} from 'sentry/utils/analytics';
-import {AddIntegration} from './addIntegration';
+import type {AddIntegrationParams} from './addIntegration';
+import {useAddIntegration} from './addIntegration';
interface AddIntegrationButtonProps
extends
Omit,
Pick<
- React.ComponentProps,
+ AddIntegrationParams,
'provider' | 'organization' | 'analyticsParams' | 'modalParams'
> {
onAddIntegration: (data: IntegrationWithConfig) => void;
@@ -40,37 +41,35 @@ export function AddIntegrationButton({
? t('Reinstall')
: t('Add %s', provider.metadata.noun));
+ const {startFlow} = useAddIntegration({
+ provider,
+ organization,
+ onInstall: onAddIntegration,
+ analyticsParams,
+ modalParams,
+ });
+
return (
- {
+ if (label === t('Reinstall')) {
+ trackAnalytics('integrations.integration_reinstall_clicked', {
+ organization,
+ provider: provider.metadata.noun,
+ });
+ }
+ startFlow();
+ }}
+ aria-label={t('Add integration')}
>
- {onClick => (
-
- )}
-
+ {label}
+
);
}
diff --git a/static/app/views/settings/organizationIntegrations/configureIntegration.tsx b/static/app/views/settings/organizationIntegrations/configureIntegration.tsx
index c9df3969854a..e947156176d6 100644
--- a/static/app/views/settings/organizationIntegrations/configureIntegration.tsx
+++ b/static/app/views/settings/organizationIntegrations/configureIntegration.tsx
@@ -50,7 +50,7 @@ import {useRoutes} from 'sentry/utils/useRoutes';
import {BreadcrumbTitle} from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader';
-import {AddIntegration} from './addIntegration';
+import {useAddIntegration} from './addIntegration';
import {IntegrationAlertRules} from './integrationAlertRules';
import {IntegrationCodeMappings} from './integrationCodeMappings';
import {IntegrationExternalTeamMappings} from './integrationExternalTeamMappings';
@@ -256,23 +256,12 @@ function ConfigureIntegration() {
const getAction = () => {
if (provider.key === 'pagerduty') {
return (
-
- {onClick => (
- }
- onClick={() => onClick()}
- >
- {t('Add Services')}
-
- )}
-
+ />
);
}
@@ -564,6 +553,26 @@ function ConfigureIntegration() {
);
}
+function PagerdutyAddServicesButton({
+ provider,
+ onInstall,
+ account,
+ organization,
+}: {
+ account: string | null;
+ onInstall: () => void;
+ organization: Organization;
+ provider: IntegrationProvider;
+}) {
+ const {startFlow} = useAddIntegration({provider, onInstall, account, organization});
+
+ return (
+ } onClick={() => startFlow()}>
+ {t('Add Services')}
+
+ );
+}
+
const TabsContainer = styled('div')`
margin-bottom: ${p => p.theme.space.xl};
`;
diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx
new file mode 100644
index 000000000000..ad498ad7c250
--- /dev/null
+++ b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.spec.tsx
@@ -0,0 +1,64 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {DataCategoryExact} from 'sentry/types/core';
+
+import {PlanMigrationRow} from 'getsentry/views/subscriptionPage/planMigrationActive/planMigrationRow';
+
+function renderRow(props: React.ComponentProps) {
+ return render(
+
+ );
+}
+
+describe('PlanMigrationRow', () => {
+ it.each([
+ ['attachment', DataCategoryExact.ATTACHMENT, 'attachments'],
+ ['log_byte', DataCategoryExact.LOG_BYTE, 'logBytes'],
+ ['trace_metric_byte', DataCategoryExact.TRACE_METRIC_BYTE, 'traceMetricBytes'],
+ ])(
+ 'renders byte category %s with GB suffix and no appended display name',
+ (_label, category, testIdSuffix) => {
+ renderRow({type: category, currentValue: 10, nextValue: 20});
+
+ const currentCell = screen.getByTestId(`current-${testIdSuffix}`);
+ const newCell = screen.getByTestId(`new-${testIdSuffix}`);
+
+ expect(currentCell).toHaveTextContent(/GB$/);
+ expect(newCell).toHaveTextContent(/GB$/);
+ }
+ );
+
+ it('renders PROFILE_DURATION with hours suffix', () => {
+ renderRow({
+ type: DataCategoryExact.PROFILE_DURATION,
+ currentValue: 1,
+ nextValue: 5,
+ });
+
+ const currentCell = screen.getByTestId('current-profileDuration');
+ const newCell = screen.getByTestId('new-profileDuration');
+
+ expect(currentCell).toHaveTextContent(/hour$/);
+ expect(newCell).toHaveTextContent(/hours$/);
+ });
+
+ it('renders count category with display name', () => {
+ renderRow({
+ type: DataCategoryExact.ERROR,
+ currentValue: 50000,
+ nextValue: 100000,
+ });
+
+ const currentCell = screen.getByTestId('current-errors');
+ const newCell = screen.getByTestId('new-errors');
+
+ expect(currentCell).not.toHaveTextContent(/GB/);
+ expect(newCell).not.toHaveTextContent(/GB/);
+ expect(currentCell).toHaveTextContent(/errors?$/i);
+ expect(newCell).toHaveTextContent(/errors?$/i);
+ });
+});
diff --git a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx
index b26fc380557d..8d0413e8b061 100644
--- a/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx
+++ b/static/gsApp/views/subscriptionPage/planMigrationActive/planMigrationRow.tsx
@@ -59,10 +59,7 @@ function formatCategoryRowString(
DATA_CATEGORY_INFO[category].plural as DataCategory,
options
);
- if (
- category === DataCategoryExact.ATTACHMENT ||
- category === DataCategoryExact.LOG_BYTE
- ) {
+ if (DATA_CATEGORY_INFO[category].formatting.unitType === 'bytes') {
return reservedWithUnits;
}
diff --git a/tests/sentry/api/endpoints/test_project_rule_details.py b/tests/sentry/api/endpoints/test_project_rule_details.py
index 3702b6ea4954..1880883e0a1f 100644
--- a/tests/sentry/api/endpoints/test_project_rule_details.py
+++ b/tests/sentry/api/endpoints/test_project_rule_details.py
@@ -1977,6 +1977,47 @@ def test_dual_written_rule_parity(self) -> None:
assert legacy_response.data["id"] == str(rule.id)
assert_serializer_parity(old=legacy_response.data, new=we_response.data)
+ def test_snoozed_rule_for_everyone_parity(self) -> None:
+ rule = self.create_project_rule(
+ project=self.project,
+ name="Snoozed for everyone alert",
+ action_match="any",
+ frequency=60,
+ condition_data=[
+ {
+ "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
+ "name": "A new issue is created",
+ },
+ ],
+ action_data=[
+ {
+ "targetType": "IssueOwners",
+ "fallthroughType": "ActiveMembers",
+ "id": "sentry.mail.actions.NotifyEmailAction",
+ "targetIdentifier": "",
+ "name": "Send a notification to IssueOwners and if none can be found then send a notification to ActiveMembers",
+ }
+ ],
+ )
+ self.snooze_rule(owner_id=self.user.id, rule=rule)
+
+ legacy_response = self.get_success_response(
+ self.organization.slug, self.project.slug, rule.id, status_code=200
+ )
+ with self.feature("organizations:workflow-engine-rule-serializers"):
+ we_response = self.get_success_response(
+ self.organization.slug, self.project.slug, rule.id, status_code=200
+ )
+
+ assert legacy_response.data["id"] == str(rule.id)
+ assert legacy_response.data["snooze"]
+ assert legacy_response.data["snoozeForEveryone"]
+ assert_serializer_parity(
+ old=legacy_response.data,
+ new=we_response.data,
+ known_differences={"snoozeCreatedBy"},
+ )
+
def test_dual_written_rule_with_filters_parity(self) -> None:
rule = self.create_project_rule(
project=self.project,
diff --git a/tests/sentry/api/endpoints/test_project_rules.py b/tests/sentry/api/endpoints/test_project_rules.py
index 3a319a7679da..601e0a780ce9 100644
--- a/tests/sentry/api/endpoints/test_project_rules.py
+++ b/tests/sentry/api/endpoints/test_project_rules.py
@@ -1673,3 +1673,53 @@ def test_dual_written_rule_parity(self) -> None:
assert legacy_rule["id"] == str(rule.id)
assert_serializer_parity(old=legacy_rule, new=we_rule)
+
+ def test_snoozed_rule_for_everyone_parity(self) -> None:
+ self.login_as(user=self.user)
+ rule = self.create_project_rule(
+ project=self.project,
+ name="Snoozed for everyone alert",
+ action_match="any",
+ frequency=60,
+ condition_data=[
+ {
+ "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition",
+ "name": "A new issue is created",
+ },
+ ],
+ action_data=[
+ {
+ "targetType": "IssueOwners",
+ "fallthroughType": "ActiveMembers",
+ "id": "sentry.mail.actions.NotifyEmailAction",
+ "targetIdentifier": "",
+ "name": "Send a notification to IssueOwners and if none can be found then send a notification to ActiveMembers",
+ }
+ ],
+ )
+ self.snooze_rule(owner_id=self.user.id, rule=rule)
+
+ legacy_response = self.get_success_response(
+ self.organization.slug,
+ self.project.slug,
+ status_code=status.HTTP_200_OK,
+ )
+
+ with self.feature("organizations:workflow-engine-rule-serializers"):
+ we_response = self.get_success_response(
+ self.organization.slug,
+ self.project.slug,
+ status_code=status.HTTP_200_OK,
+ )
+
+ assert len(legacy_response.data) == 1
+ assert len(we_response.data) == 1
+ legacy_rule = legacy_response.data[0]
+ we_rule = we_response.data[0]
+ assert legacy_rule["id"] == str(rule.id)
+ assert legacy_rule["snooze"]
+ assert_serializer_parity(
+ old=legacy_rule,
+ new=we_rule,
+ known_differences={"snoozeCreatedBy"},
+ )
diff --git a/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py b/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py
index 04f154714cc3..80a5ec22eb6f 100644
--- a/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py
+++ b/tests/sentry/incidents/subscription_processor/test_subscription_processor_aci.py
@@ -1,6 +1,5 @@
import copy
import math
-from contextlib import contextmanager
from datetime import timedelta
from functools import cached_property
from unittest.mock import MagicMock, call, patch
@@ -12,9 +11,8 @@
from urllib3.response import HTTPResponse
from sentry.constants import ObjectStatus
-from sentry.incidents.subscription_processor import SubscriptionProcessor, has_downgraded
+from sentry.incidents.subscription_processor import SubscriptionProcessor
from sentry.incidents.utils.types import QuerySubscriptionUpdate
-from sentry.models.organization import Organization
from sentry.seer.anomaly_detection.types import (
AnomalyDetectionSeasonality,
AnomalyDetectionSensitivity,
@@ -65,17 +63,18 @@ def test_missing_project(self, mock_metrics: MagicMock) -> None:
self.sub.project.delete()
assert self.send_update(self.critical_threshold + 1) is False
- @patch("sentry.incidents.subscription_processor.has_downgraded", return_value=True)
- def test_process_update_returns_false_when_downgraded(
- self, mock_has_downgraded: MagicMock
- ) -> None:
+ @patch(
+ "sentry.incidents.subscription_processor.is_metric_subscription_allowed",
+ return_value=False,
+ )
+ def test_process_update_returns_false_when_downgraded(self, mock_is_enabled: MagicMock) -> None:
message = self.build_subscription_update(
self.sub, value=self.critical_threshold + 1, time_delta=timedelta()
)
with self.capture_on_commit_callbacks(execute=True):
assert SubscriptionProcessor.process(self.sub, message) is False
- mock_has_downgraded.assert_called_once()
+ mock_is_enabled.assert_called_once()
@patch("sentry.incidents.subscription_processor.metrics")
def test_invalid_aggregation_value(self, mock_metrics: MagicMock) -> None:
@@ -1012,92 +1011,3 @@ def test_ensure_case_when_no_metrics_index_not_found_is_handled_gracefully(
call("incidents.alert_rules.skipping_update_invalid_aggregation_value"),
]
)
-
-
-@patch("sentry.incidents.subscription_processor.metrics")
-class TestHasDowngraded:
- org = MagicMock(spec=Organization)
-
- @contextmanager
- def fake_features(self, enabled: set[str]):
- with patch("sentry.incidents.subscription_processor.features") as mock_features:
- mock_features.has.side_effect = lambda name, *a, **kw: name in enabled
- yield
-
- def test_events_without_incidents_feature(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(set()):
- assert has_downgraded(Dataset.Events.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_incidents"
- )
-
- def test_events_with_incidents_feature(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:incidents"}):
- assert has_downgraded(Dataset.Events.value, self.org) is False
-
- def test_transactions_without_any_features(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(set()):
- assert has_downgraded(Dataset.Transactions.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_incidents_performance"
- )
-
- def test_transactions_with_only_performance_view(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:performance-view"}):
- assert has_downgraded(Dataset.Transactions.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_incidents_performance"
- )
-
- def test_transactions_with_both_features(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:incidents", "organizations:performance-view"}):
- assert has_downgraded(Dataset.Transactions.value, self.org) is False
-
- def test_eap_without_features(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(set()):
- assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_incidents_eap"
- )
-
- def test_eap_with_only_explore_view(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:visibility-explore-view"}):
- assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_incidents_eap"
- )
-
- def test_eap_with_both_features(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(
- {"organizations:incidents", "organizations:visibility-explore-view"}
- ):
- assert has_downgraded(Dataset.EventsAnalyticsPlatform.value, self.org) is False
-
- def test_performance_metrics_without_on_demand_feature(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(set()):
- assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is True
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.ignore_update_missing_on_demand"
- )
-
- def test_performance_metrics_with_on_demand_feature(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:on-demand-metrics-extraction"}):
- assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is False
-
- def test_unknown_dataset_not_downgraded(self, mock_metrics: MagicMock) -> None:
- with self.fake_features(set()):
- assert has_downgraded("unknown_dataset", self.org) is False
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.no_incidents_not_downgraded",
- sample_rate=1.0,
- tags={"dataset": "unknown_dataset"},
- )
-
- def test_no_incidents_not_downgraded_emits_metric(self, mock_metrics: MagicMock) -> None:
- with self.fake_features({"organizations:on-demand-metrics-extraction"}):
- assert has_downgraded(Dataset.PerformanceMetrics.value, self.org) is False
- mock_metrics.incr.assert_called_with(
- "incidents.alert_rules.no_incidents_not_downgraded",
- sample_rate=1.0,
- tags={"dataset": Dataset.PerformanceMetrics.value},
- )
diff --git a/tests/sentry/incidents/utils/test_subscription_limits.py b/tests/sentry/incidents/utils/test_subscription_limits.py
new file mode 100644
index 000000000000..924fc364e25d
--- /dev/null
+++ b/tests/sentry/incidents/utils/test_subscription_limits.py
@@ -0,0 +1,100 @@
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+from sentry.incidents.utils.subscription_limits import is_metric_subscription_allowed
+from sentry.models.organization import Organization
+from sentry.snuba.dataset import Dataset
+
+
+class TestIsMetricSubscriptionAllowed:
+ org = MagicMock(spec=Organization)
+
+ @contextmanager
+ def fake_features(self, enabled: set[str]):
+ with patch("sentry.incidents.utils.subscription_limits.features") as mock_features:
+ mock_features.has.side_effect = lambda name, *a, **kw: name in enabled
+ yield
+
+ # -- Events: requires :incidents --
+
+ def test_events_without_incidents(self) -> None:
+ with self.fake_features(set()):
+ assert is_metric_subscription_allowed(Dataset.Events.value, self.org) is False
+
+ def test_events_with_incidents(self) -> None:
+ with self.fake_features({"organizations:incidents"}):
+ assert is_metric_subscription_allowed(Dataset.Events.value, self.org) is True
+
+ # -- Transactions: requires :incidents + :performance-view --
+
+ def test_transactions_without_any_features(self) -> None:
+ with self.fake_features(set()):
+ assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
+
+ def test_transactions_with_only_performance_view(self) -> None:
+ with self.fake_features({"organizations:performance-view"}):
+ assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
+
+ def test_transactions_with_only_incidents(self) -> None:
+ with self.fake_features({"organizations:incidents"}):
+ assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is False
+
+ def test_transactions_with_both_features(self) -> None:
+ with self.fake_features({"organizations:incidents", "organizations:performance-view"}):
+ assert is_metric_subscription_allowed(Dataset.Transactions.value, self.org) is True
+
+ # -- EAP: requires :incidents + :visibility-explore-view --
+
+ def test_eap_without_any_features(self) -> None:
+ with self.fake_features(set()):
+ assert (
+ is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
+ is False
+ )
+
+ def test_eap_with_only_explore_view(self) -> None:
+ with self.fake_features({"organizations:visibility-explore-view"}):
+ assert (
+ is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
+ is False
+ )
+
+ def test_eap_with_only_incidents(self) -> None:
+ with self.fake_features({"organizations:incidents"}):
+ assert (
+ is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
+ is False
+ )
+
+ def test_eap_with_both_features(self) -> None:
+ with self.fake_features(
+ {"organizations:incidents", "organizations:visibility-explore-view"}
+ ):
+ assert (
+ is_metric_subscription_allowed(Dataset.EventsAnalyticsPlatform.value, self.org)
+ is True
+ )
+
+ # -- PerformanceMetrics: requires :on-demand-metrics-extraction only --
+
+ def test_performance_metrics_without_on_demand(self) -> None:
+ with self.fake_features(set()):
+ assert (
+ is_metric_subscription_allowed(Dataset.PerformanceMetrics.value, self.org) is False
+ )
+
+ def test_performance_metrics_with_on_demand(self) -> None:
+ with self.fake_features({"organizations:on-demand-metrics-extraction"}):
+ assert (
+ is_metric_subscription_allowed(Dataset.PerformanceMetrics.value, self.org) is True
+ )
+
+ # -- Unknown / other datasets: always allowed --
+
+ def test_unknown_dataset_without_incidents(self) -> None:
+ with self.fake_features(set()):
+ assert is_metric_subscription_allowed("unknown_dataset", self.org) is True
+
+ def test_unknown_dataset_with_incidents(self) -> None:
+ with self.fake_features({"organizations:incidents"}):
+ assert is_metric_subscription_allowed("unknown_dataset", self.org) is True
diff --git a/tests/sentry/middleware/test_viewer_context.py b/tests/sentry/middleware/test_viewer_context.py
index 3cb30e7f4818..d73343e5bbd2 100644
--- a/tests/sentry/middleware/test_viewer_context.py
+++ b/tests/sentry/middleware/test_viewer_context.py
@@ -103,6 +103,7 @@ def setUp(self):
super().setUp()
self.factory = RequestFactory()
+ @override_options({"viewer-context.enabled": False})
def test_skipped_when_disabled(self):
captured: list = []
@@ -110,7 +111,6 @@ def get_response(request):
captured.append(get_viewer_context())
return MagicMock(status_code=200)
- # Default: viewer-context.enabled is False
middleware = ViewerContextMiddleware(get_response)
request = self.factory.get("/")
diff --git a/tests/sentry/notifications/api/endpoints/test_notification_defaults.py b/tests/sentry/notifications/api/endpoints/test_notification_defaults.py
index 99475074dbc8..d66877c298ab 100644
--- a/tests/sentry/notifications/api/endpoints/test_notification_defaults.py
+++ b/tests/sentry/notifications/api/endpoints/test_notification_defaults.py
@@ -30,6 +30,7 @@ def test_basic(self) -> None:
"quotaProfileDurationUI": "always",
"quotaSeerBudget": "always",
"quotaLogBytes": "always",
+ "quotaTraceMetricBytes": "always",
"reports": "always",
"spikeProtection": "always",
"workflow": "subscribe_only",
diff --git a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_options.py b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_options.py
index cfaa96c7e2b5..4dbe450dc81c 100644
--- a/tests/sentry/notifications/api/endpoints/test_user_notification_settings_options.py
+++ b/tests/sentry/notifications/api/endpoints/test_user_notification_settings_options.py
@@ -104,6 +104,7 @@ def test_user_scope(self) -> None:
NotificationSettingEnum.QUOTA_MONITOR_SEATS,
NotificationSettingEnum.QUOTA_SPANS,
NotificationSettingEnum.QUOTA_LOG_BYTES,
+ NotificationSettingEnum.QUOTA_TRACE_METRIC_BYTES,
NotificationSettingEnum.QUOTA_SEER_USERS,
NotificationSettingEnum.QUOTA_SIZE_ANALYSIS,
]
diff --git a/tests/sentry/workflow_engine/defaults/test_detectors.py b/tests/sentry/workflow_engine/defaults/test_detectors.py
new file mode 100644
index 000000000000..153d0506a76b
--- /dev/null
+++ b/tests/sentry/workflow_engine/defaults/test_detectors.py
@@ -0,0 +1,51 @@
+from unittest.mock import patch
+
+import pytest
+
+from sentry.grouping.grouptype import ErrorGroupType
+from sentry.testutils.cases import TestCase
+from sentry.utils.locking import UnableToAcquireLock
+from sentry.workflow_engine.defaults.detectors import (
+ UnableToAcquireLockApiError,
+ ensure_default_detectors,
+)
+from sentry.workflow_engine.models import Detector
+from sentry.workflow_engine.types import ERROR_DETECTOR_NAME, ISSUE_STREAM_DETECTOR_NAME
+from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
+
+
+class TestEnsureDefaultDetectors(TestCase):
+ def setUp(self) -> None:
+ self.slugs = [ErrorGroupType.slug, IssueStreamGroupType.slug]
+ self.names = [ERROR_DETECTOR_NAME, ISSUE_STREAM_DETECTOR_NAME]
+
+ def test_ensure_default_detector(self) -> None:
+ project = self.create_project()
+ detectors = ensure_default_detectors(project)
+
+ error_detector = detectors[ErrorGroupType.slug]
+ assert error_detector.name == ERROR_DETECTOR_NAME
+ assert error_detector.project_id == project.id
+ assert error_detector.type == ErrorGroupType.slug
+
+ issue_stream_detector = detectors[IssueStreamGroupType.slug]
+ assert issue_stream_detector.name == ISSUE_STREAM_DETECTOR_NAME
+ assert issue_stream_detector.project_id == project.id
+ assert issue_stream_detector.type == IssueStreamGroupType.slug
+
+ def test_ensure_default_detector__already_exists(self) -> None:
+ project = self.create_project()
+ existing = Detector.objects.filter(project=project)
+
+ with patch("sentry.workflow_engine.defaults.detectors.locks.get") as mock_lock:
+ default_detectors = ensure_default_detectors(project)
+ assert {d.id for d in default_detectors.values()} == {d.id for d in existing}
+ # No lock if it already exists.
+ mock_lock.assert_not_called()
+
+ def test_ensure_default_detector__lock_fails(self) -> None:
+ with patch("sentry.workflow_engine.defaults.detectors.locks.get") as mock_lock:
+ mock_lock.return_value.blocking_acquire.side_effect = UnableToAcquireLock
+ with pytest.raises(UnableToAcquireLockApiError):
+ project = self.create_project()
+ ensure_default_detectors(project)
diff --git a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_migration.py b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_migration.py
index a8581b32748c..3a4059f13c1b 100644
--- a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_migration.py
+++ b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_migration.py
@@ -25,7 +25,6 @@
from sentry.rules.match import MatchType
from sentry.testutils.cases import TestCase
from sentry.testutils.helpers import install_slack
-from sentry.utils.locking import UnableToAcquireLock
from sentry.workflow_engine.migration_helpers.issue_alert_migration import IssueAlertMigrator
from sentry.workflow_engine.models import (
Action,
@@ -40,11 +39,6 @@
WorkflowDataConditionGroup,
)
from sentry.workflow_engine.models.data_condition import Condition
-from sentry.workflow_engine.processors.detector import (
- UnableToAcquireLockApiError,
- ensure_default_detectors,
-)
-from sentry.workflow_engine.types import ERROR_DETECTOR_NAME, ISSUE_STREAM_DETECTOR_NAME
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
@@ -626,39 +620,3 @@ def test_dry_run__action_validation_fails(self) -> None:
IssueAlertMigrator(issue_alert, self.user.id, is_dry_run=True).run()
self.assert_nothing_migrated(issue_alert)
-
-
-class TestEnsureDefaultDetectors(TestCase):
- def setUp(self) -> None:
- self.slugs = [ErrorGroupType.slug, IssueStreamGroupType.slug]
- self.names = [ERROR_DETECTOR_NAME, ISSUE_STREAM_DETECTOR_NAME]
-
- def test_ensure_default_detector(self) -> None:
- project = self.create_project()
- detectors = ensure_default_detectors(project)
-
- error_detector = detectors[ErrorGroupType.slug]
- assert error_detector.name == ERROR_DETECTOR_NAME
- assert error_detector.project_id == project.id
- assert error_detector.type == ErrorGroupType.slug
-
- issue_stream_detector = detectors[IssueStreamGroupType.slug]
- assert issue_stream_detector.name == ISSUE_STREAM_DETECTOR_NAME
- assert issue_stream_detector.project_id == project.id
- assert issue_stream_detector.type == IssueStreamGroupType.slug
-
- def test_ensure_default_detector__already_exists(self) -> None:
- project = self.create_project()
- existing = Detector.objects.filter(project=project)
- with patch("sentry.workflow_engine.processors.detector.locks.get") as mock_lock:
- default_detectors = ensure_default_detectors(project)
- assert {d.id for d in default_detectors.values()} == {d.id for d in existing}
- # No lock if it already exists.
- mock_lock.assert_not_called()
-
- def test_ensure_default_detector__lock_fails(self) -> None:
- with patch("sentry.workflow_engine.processors.detector.locks.get") as mock_lock:
- mock_lock.return_value.blocking_acquire.side_effect = UnableToAcquireLock
- with pytest.raises(UnableToAcquireLockApiError):
- project = self.create_project()
- ensure_default_detectors(project)
diff --git a/tests/sentry/receivers/test_default_detector.py b/tests/sentry/workflow_engine/receivers/test_project_detectors.py
similarity index 91%
rename from tests/sentry/receivers/test_default_detector.py
rename to tests/sentry/workflow_engine/receivers/test_project_detectors.py
index 9a794c573eea..4b1c6a1f3704 100644
--- a/tests/sentry/receivers/test_default_detector.py
+++ b/tests/sentry/workflow_engine/receivers/test_project_detectors.py
@@ -8,20 +8,20 @@
from sentry.incidents.models.alert_rule import AlertRuleDetectionType
from sentry.issue_detection.performance_detection import PERFORMANCE_DETECTOR_CONFIG_MAPPINGS
from sentry.models.project import Project
-from sentry.receivers.project_detectors import (
- create_default_anomaly_detector,
- disable_default_detector_creation,
-)
from sentry.signals import project_created
from sentry.snuba.models import QuerySubscription
from sentry.testutils.cases import TestCase
from sentry.testutils.helpers.features import with_feature
-from sentry.workflow_engine.models import DataSource, Detector
-from sentry.workflow_engine.models.data_condition import Condition, DataCondition
-from sentry.workflow_engine.processors.detector import (
+from sentry.workflow_engine.defaults.detectors import (
ensure_default_anomaly_detector,
ensure_performance_detectors,
)
+from sentry.workflow_engine.models import DataSource, Detector
+from sentry.workflow_engine.models.data_condition import Condition, DataCondition
+from sentry.workflow_engine.receivers.project_detectors import (
+ create_default_anomaly_detector,
+ disable_default_detector_creation,
+)
from sentry.workflow_engine.types import DetectorPriorityLevel
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
@@ -34,7 +34,7 @@ def test_creates_detector_with_all_components(self) -> None:
project.add_team(team)
with mock.patch(
- "sentry.workflow_engine.processors.detector.send_new_detector_data"
+ "sentry.workflow_engine.defaults.detectors.send_new_detector_data"
) as mock_send:
detector = ensure_default_anomaly_detector(
project, owner_team_id=team.id, enabled=False
@@ -77,7 +77,7 @@ def test_creates_detector_without_team(self) -> None:
"""Test that detector can be created without an owner team."""
project = self.create_project()
- with mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"):
+ with mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"):
detector = ensure_default_anomaly_detector(project, owner_team_id=None, enabled=True)
assert detector is not None
@@ -89,7 +89,7 @@ def test_send_new_detector_data_failure_blocks_creation(self) -> None:
project = self.create_project()
with mock.patch(
- "sentry.workflow_engine.processors.detector.send_new_detector_data",
+ "sentry.workflow_engine.defaults.detectors.send_new_detector_data",
side_effect=Exception("Seer unavailable"),
):
with pytest.raises(Exception, match="Seer unavailable"):
@@ -102,7 +102,7 @@ def test_returns_existing_detector_without_creating_duplicates(self) -> None:
"""Test that calling ensure_default_anomaly_detector twice returns the same detector."""
project = self.create_project()
- with mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"):
+ with mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"):
detector1 = ensure_default_anomaly_detector(project)
detector2 = ensure_default_anomaly_detector(project)
@@ -124,7 +124,7 @@ def test_creates_enabled_detector_when_both_features_enabled(self) -> None:
team = project.teams.first()
assert team is not None
- with mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"):
+ with mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"):
create_default_anomaly_detector(project, user=self.user)
detector = Detector.objects.get(project=project, type=MetricIssue.slug)
@@ -137,7 +137,7 @@ def test_creates_disabled_detector_when_plan_feature_missing(self) -> None:
"""Test that detector is created but disabled when anomaly-detection-alerts is off."""
project = self.create_project()
- with mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"):
+ with mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"):
create_default_anomaly_detector(project, user=self.user)
detector = Detector.objects.get(project=project, type=MetricIssue.slug)
@@ -159,7 +159,7 @@ def test_creates_detector_without_team(self) -> None:
# Remove all teams
project.teams.clear()
- with mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"):
+ with mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"):
create_default_anomaly_detector(project, user=self.user)
detector = Detector.objects.get(project=project, type=MetricIssue.slug)
@@ -197,7 +197,7 @@ def test_context_manager_disables_metric_detector_signal(self) -> None:
"""Test that disable_default_detector_creation also prevents metric detector creation."""
with (
disable_default_detector_creation(),
- mock.patch("sentry.workflow_engine.processors.detector.send_new_detector_data"),
+ mock.patch("sentry.workflow_engine.defaults.detectors.send_new_detector_data"),
):
# fire_project_created=True ensures the project_created signal is sent
project = self.create_project(fire_project_created=True)
@@ -248,7 +248,7 @@ def test_disable_default_detector_creation_prevents_performance_detectors(self)
@with_feature("projects:workflow-engine-performance-detectors")
@mock.patch(
- "sentry.workflow_engine.processors.detector.DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS",
+ "sentry.workflow_engine.defaults.detectors.DEFAULT_PROJECT_PERFORMANCE_DETECTION_SETTINGS",
{
"slow_db_queries_detection_enabled": True,
"large_http_payload_detection_enabled": True,
@@ -256,12 +256,12 @@ def test_disable_default_detector_creation_prevents_performance_detectors(self)
},
)
@mock.patch(
- "sentry.workflow_engine.processors.detector.get_disabled_platforms_by_detector_type",
+ "sentry.workflow_engine.defaults.detectors.get_disabled_platforms_by_detector_type",
return_value={
"performance_slow_db_query": frozenset({"ruby", "php"}),
},
)
- def test_respects_default_enabled_state(self, mock_disabled_platforms):
+ def test_respects_default_enabled_state(self, mock_disabled: mock.MagicMock) -> None:
"""Test that detectors respect both platform-specific disabling and default enabled state."""
with disable_default_detector_creation():
project = self.create_project(platform="ruby")
diff --git a/tests/sentry/workflow_engine/tasks/test_cleanup.py b/tests/sentry/workflow_engine/tasks/test_cleanup.py
index da54d35d4946..75cc53e9b277 100644
--- a/tests/sentry/workflow_engine/tasks/test_cleanup.py
+++ b/tests/sentry/workflow_engine/tasks/test_cleanup.py
@@ -3,76 +3,80 @@
from django.utils import timezone
+from sentry.models.groupopenperiod import GroupOpenPeriod
+from sentry.models.groupopenperiodactivity import GroupOpenPeriodActivity, OpenPeriodActivityType
from sentry.testutils.cases import TestCase
from sentry.testutils.helpers.options import override_options
from sentry.utils.query import bulk_delete_objects
-from sentry.workflow_engine.models import WorkflowFireHistory
from sentry.workflow_engine.tasks.cleanup import (
- FIRE_HISTORY_RETENTION_DAYS,
- prune_old_fire_history,
+ OPEN_PERIOD_ACTIVITY_RETENTION_DAYS,
+ prune_old_open_period_activity,
)
-class TestPruneOldFireHistory(TestCase):
- def _create_fire_history(self, days_ago: int) -> WorkflowFireHistory:
- workflow = self.create_workflow(organization=self.organization)
+class TestPruneOldOpenPeriodActivity(TestCase):
+ def _create_activity(self, days_ago: int) -> GroupOpenPeriodActivity:
group = self.create_group(project=self.project)
- obj = WorkflowFireHistory.objects.create(workflow=workflow, group=group, event_id="abc123")
+ open_period = GroupOpenPeriod.objects.get(group=group)
+ obj = GroupOpenPeriodActivity.objects.create(
+ group_open_period=open_period,
+ type=OpenPeriodActivityType.OPENED,
+ )
if days_ago > 0:
backdated = timezone.now() - timedelta(days=days_ago)
- WorkflowFireHistory.objects.filter(id=obj.id).update(date_added=backdated)
+ GroupOpenPeriodActivity.objects.filter(id=obj.id).update(date_added=backdated)
obj.refresh_from_db()
return obj
def test_noop_when_nothing_to_delete(self) -> None:
with patch("sentry.workflow_engine.tasks.cleanup.metrics") as mock_metrics:
- prune_old_fire_history()
+ prune_old_open_period_activity()
mock_metrics.incr.assert_called_once_with(
- "workflow_engine.tasks.prune_old_fire_history.batches_deleted",
+ "workflow_engine.tasks.prune_old_open_period_activity.batches_deleted",
amount=0,
sample_rate=1.0,
)
def test_deletes_rows_older_than_retention(self) -> None:
- old = self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS + 1)
- recent = self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS - 1)
+ old = self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS + 1)
+ recent = self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS - 1)
- prune_old_fire_history()
+ prune_old_open_period_activity()
- assert not WorkflowFireHistory.objects.filter(id=old.id).exists()
- assert WorkflowFireHistory.objects.filter(id=recent.id).exists()
+ assert not GroupOpenPeriodActivity.objects.filter(id=old.id).exists()
+ assert GroupOpenPeriodActivity.objects.filter(id=recent.id).exists()
def test_preserves_recent_rows(self) -> None:
rows = [
- self._create_fire_history(days_ago=1),
- self._create_fire_history(days_ago=30),
- self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS - 1),
+ self._create_activity(days_ago=1),
+ self._create_activity(days_ago=30),
+ self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS - 1),
]
- prune_old_fire_history()
+ prune_old_open_period_activity()
for row in rows:
- assert WorkflowFireHistory.objects.filter(id=row.id).exists()
+ assert GroupOpenPeriodActivity.objects.filter(id=row.id).exists()
- @override_options({"workflow_engine.fire_history_cleanup.batch_size": 10})
+ @override_options({"workflow_engine.open_period_activity_cleanup.batch_size": 10})
def test_multiple_batches(self) -> None:
old_ids = []
for _ in range(25):
- obj = self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS + 1)
+ obj = self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS + 1)
old_ids.append(obj.id)
with patch("sentry.workflow_engine.tasks.cleanup.metrics") as mock_metrics:
- prune_old_fire_history()
+ prune_old_open_period_activity()
- assert WorkflowFireHistory.objects.filter(id__in=old_ids).count() == 0
+ assert GroupOpenPeriodActivity.objects.filter(id__in=old_ids).count() == 0
mock_metrics.incr.assert_called_once()
assert mock_metrics.incr.call_args.kwargs["amount"] >= 2
@override_options(
{
- "workflow_engine.fire_history_cleanup.batch_size": 10,
- "workflow_engine.fire_history_cleanup.time_limit_seconds": 5.0,
+ "workflow_engine.open_period_activity_cleanup.batch_size": 10,
+ "workflow_engine.open_period_activity_cleanup.time_limit_seconds": 5.0,
}
)
@patch("sentry.workflow_engine.tasks.cleanup.time")
@@ -82,41 +86,41 @@ def test_time_bounded_leaves_remaining_rows(self, mock_time: MagicMock) -> None:
old_ids = []
for _ in range(25):
- obj = self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS + 1)
+ obj = self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS + 1)
old_ids.append(obj.id)
with patch("sentry.workflow_engine.tasks.cleanup.metrics"):
- prune_old_fire_history()
+ prune_old_open_period_activity()
- remaining = WorkflowFireHistory.objects.filter(id__in=old_ids).count()
+ remaining = GroupOpenPeriodActivity.objects.filter(id__in=old_ids).count()
assert remaining == 15 # only one batch of 10 was deleted
- @override_options({"workflow_engine.fire_history_cleanup.batch_size": 5})
+ @override_options({"workflow_engine.open_period_activity_cleanup.batch_size": 5})
def test_options_honored(self) -> None:
old_ids = []
for _ in range(12):
- obj = self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS + 1)
+ obj = self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS + 1)
old_ids.append(obj.id)
with patch(
"sentry.workflow_engine.tasks.cleanup.bulk_delete_objects",
wraps=bulk_delete_objects,
) as spy:
- prune_old_fire_history()
+ prune_old_open_period_activity()
for call in spy.call_args_list:
assert call.kwargs["limit"] == 5
- assert WorkflowFireHistory.objects.filter(id__in=old_ids).count() == 0
+ assert GroupOpenPeriodActivity.objects.filter(id__in=old_ids).count() == 0
def test_metrics_emitted(self) -> None:
- self._create_fire_history(days_ago=FIRE_HISTORY_RETENTION_DAYS + 1)
+ self._create_activity(days_ago=OPEN_PERIOD_ACTIVITY_RETENTION_DAYS + 1)
with patch("sentry.workflow_engine.tasks.cleanup.metrics") as mock_metrics:
- prune_old_fire_history()
+ prune_old_open_period_activity()
mock_metrics.incr.assert_called_once_with(
- "workflow_engine.tasks.prune_old_fire_history.batches_deleted",
+ "workflow_engine.tasks.prune_old_open_period_activity.batches_deleted",
amount=1, # 1 row fits in a single batch
sample_rate=1.0,
)