Repository: getsentry/sentry
Pull request #91937: chore(seer): Use seer quotas endpoints
Author: login: roaga
Labels: name: Scope: Backend
description: Automatically applied to PRs that change backend components
name: Trigger: Revert
description: Add to a merged PR to revert it (skips CI)
Created: 2025-05-20T15:56:40Z | Merged: 2025-05-22T13:44:12Z

Records billable Seer events and checks for available budget before running Seer features. Will follow up with trial status + UI work.


Diff:
diff --git a/src/sentry/api/endpoints/group_ai_summary.py b/src/sentry/api/endpoints/group_ai_summary.py
index 9e4be89959e5e4..81993ebbabacc0 100644
--- a/src/sentry/api/endpoints/group_ai_summary.py
+++ b/src/sentry/api/endpoints/group_ai_summary.py
@@ -10,6 +10,7 @@
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.group import GroupAiEndpoint
+from sentry.autofix.utils import SeerAutomationSource
 from sentry.models.group import Group
 from sentry.seer.issue_summary import get_issue_summary
 from sentry.types.ratelimit import RateLimit, RateLimitCategory
@@ -37,7 +38,10 @@ def post(self, request: Request, group: Group) -> Response:
         force_event_id = data.get("event_id", None)
 
         summary_data, status_code = get_issue_summary(
-            group=group, user=request.user, force_event_id=force_event_id, source="issue_details"
+            group=group,
+            user=request.user,
+            force_event_id=force_event_id,
+            source=SeerAutomationSource.ISSUE_DETAILS,
         )
 
         return Response(summary_data, status=status_code)
diff --git a/src/sentry/api/endpoints/group_autofix_setup_check.py b/src/sentry/api/endpoints/group_autofix_setup_check.py
index 07ae4119358175..14722fc7e2f8e2 100644
--- a/src/sentry/api/endpoints/group_autofix_setup_check.py
+++ b/src/sentry/api/endpoints/group_autofix_setup_check.py
@@ -7,12 +7,13 @@
 from django.conf import settings
 from rest_framework.response import Response
 
+from sentry import quotas
 from sentry.api.api_owners import ApiOwner
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.group import GroupAiEndpoint
 from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings
-from sentry.constants import ObjectStatus
+from sentry.constants import DataCategory, ObjectStatus
 from sentry.integrations.services.integration import integration_service
 from sentry.models.group import Group
 from sentry.models.organization import Organization
@@ -126,6 +127,11 @@ def get(self, request: Request, group: Group) -> Response:
         if not user_acknowledgement:  # If the user has acknowledged, the org must have too.
             org_acknowledgement = get_seer_org_acknowledgement(org_id=org.id)
 
+        # TODO return BOTH trial status and autofix quota
+        has_autofix_quota: bool = quotas.backend.has_available_reserved_budget(
+            org_id=org.id, data_category=DataCategory.SEER_AUTOFIX
+        )
+
         return Response(
             {
                 "integration": {
@@ -137,5 +143,8 @@ def get(self, request: Request, group: Group) -> Response:
                     "orgHasAcknowledged": org_acknowledgement,
                     "userHasAcknowledged": user_acknowledgement,
                 },
+                "billing": {
+                    "hasAutofixQuota": has_autofix_quota,
+                },
             }
         )
diff --git a/src/sentry/autofix/utils.py b/src/sentry/autofix/utils.py
index 8958c004757054..52795f5fbb679d 100644
--- a/src/sentry/autofix/utils.py
+++ b/src/sentry/autofix/utils.py
@@ -146,3 +146,9 @@ def get_autofix_state_from_pr_id(provider: str, pr_id: int) -> AutofixState | No
         return None
 
     return AutofixState.validate(result.get("state", None))
+
+
+class SeerAutomationSource(enum.Enum):
+    ISSUE_DETAILS = "issue_details"
+    ALERT = "alert"
+    POST_PROCESS = "post_process"
diff --git a/src/sentry/integrations/utils/issue_summary_for_alerts.py b/src/sentry/integrations/utils/issue_summary_for_alerts.py
index ec15b11092b17a..4718cb7b407198 100644
--- a/src/sentry/integrations/utils/issue_summary_for_alerts.py
+++ b/src/sentry/integrations/utils/issue_summary_for_alerts.py
@@ -5,6 +5,7 @@
 import sentry_sdk
 
 from sentry import features, options
+from sentry.autofix.utils import SeerAutomationSource
 from sentry.issues.grouptype import GroupCategory
 from sentry.models.group import Group
 from sentry.seer.issue_summary import get_issue_summary
@@ -32,7 +33,9 @@ def fetch_issue_summary(group: Group) -> dict[str, Any] | None:
     try:
         with sentry_sdk.start_span(op="ai_summary.fetch_issue_summary_for_alert"):
             with concurrent.futures.ThreadPoolExecutor() as executor:
-                future = executor.submit(get_issue_summary, group, source="alert")
+                future = executor.submit(
+                    get_issue_summary, group, source=SeerAutomationSource.ALERT
+                )
                 summary_result, status_code = future.result(timeout=timeout)
 
                 if status_code == 200:
diff --git a/src/sentry/seer/autofix.py b/src/sentry/seer/autofix.py
index 06501438a7a1d6..934b41bea0032e 100644
--- a/src/sentry/seer/autofix.py
+++ b/src/sentry/seer/autofix.py
@@ -11,10 +11,10 @@
 from django.utils import timezone
 from rest_framework.response import Response
 
-from sentry import eventstore, features
+from sentry import eventstore, features, quotas
 from sentry.api.serializers import EventSerializer, serialize
 from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings
-from sentry.constants import ObjectStatus
+from sentry.constants import DataCategory, ObjectStatus
 from sentry.eventstore.models import Event, GroupEvent
 from sentry.models.group import Group
 from sentry.models.project import Project
@@ -747,6 +747,14 @@ def trigger_autofix(
             403,
         )
 
+    # check billing quota for autofix
+    has_budget: bool = quotas.backend.has_available_reserved_budget(
+        org_id=group.organization.id,
+        data_category=DataCategory.SEER_AUTOFIX,
+    )
+    if not has_budget:
+        return _respond_with_error("No budget for Seer Autofix.", 402)
+
     if event_id is None:
         event: Event | GroupEvent | None = group.get_recommended_event_for_environments()
         if not event:
@@ -808,6 +816,11 @@ def trigger_autofix(
 
     group.update(seer_autofix_last_triggered=timezone.now())
 
+    # log billing event for seer autofix
+    quotas.backend.record_seer_run(
+        group.organization.id, group.project.id, DataCategory.SEER_AUTOFIX
+    )
+
     return Response(
         {
             "run_id": run_id,
diff --git a/src/sentry/seer/issue_summary.py b/src/sentry/seer/issue_summary.py
index ccc3e98cbee98d..0a6cd48bccaa47 100644
--- a/src/sentry/seer/issue_summary.py
+++ b/src/sentry/seer/issue_summary.py
@@ -10,11 +10,11 @@
 from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 
-from sentry import eventstore, features
+from sentry import eventstore, features, quotas
 from sentry.api.serializers import EventSerializer, serialize
 from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
-from sentry.autofix.utils import get_autofix_state
-from sentry.constants import ObjectStatus
+from sentry.autofix.utils import SeerAutomationSource, get_autofix_state
+from sentry.constants import DataCategory, ObjectStatus
 from sentry.eventstore.models import Event, GroupEvent
 from sentry.locks import locks
 from sentry.models.group import Group
@@ -254,7 +254,7 @@ def _run_automation(
     group: Group,
     user: User | RpcUser | AnonymousUser,
     event: GroupEvent,
-    source: str,
+    source: SeerAutomationSource,
 ):
     if features.has(
         "organizations:trigger-autofix-on-issue-summary", group.organization, actor=user
@@ -282,8 +282,9 @@ def _run_automation(
                 not autofix_state
             ):  # Only trigger autofix if we don't have an autofix on this issue already.
                 auto_run_source_map = {
-                    "issue_details": "issue_summary_fixability",
-                    "alert": "issue_summary_on_alert_fixability",
+                    SeerAutomationSource.ISSUE_DETAILS: "issue_summary_fixability",
+                    SeerAutomationSource.ALERT: "issue_summary_on_alert_fixability",
+                    SeerAutomationSource.POST_PROCESS: "issue_summary_on_post_process_fixability",
                 }
                 _trigger_autofix_task.delay(
                     group_id=group.id,
@@ -297,7 +298,7 @@ def _generate_summary(
     group: Group,
     user: User | RpcUser | AnonymousUser,
     force_event_id: str | None,
-    source: str,
+    source: SeerAutomationSource,
     cache_key: str,
 ) -> tuple[dict[str, Any], int]:
     """Core logic to generate and cache the issue summary."""
@@ -335,11 +336,29 @@ def _generate_summary(
     return summary_dict, 200
 
 
+def _log_seer_scanner_billing_event(group: Group, source: SeerAutomationSource):
+    if source == SeerAutomationSource.ISSUE_DETAILS:
+        return
+
+    quotas.backend.record_seer_run(
+        group.organization.id, group.project.id, DataCategory.SEER_SCANNER
+    )
+
+
+def _has_seer_scanner_budget(group: Group, source: SeerAutomationSource) -> bool:
+    if source == SeerAutomationSource.ISSUE_DETAILS:
+        return True
+
+    return quotas.backend.has_available_reserved_budget(
+        org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER
+    )
+
+
 def get_issue_summary(
     group: Group,
     user: User | RpcUser | AnonymousUser | None = None,
     force_event_id: str | None = None,
-    source: str = "issue_details",
+    source: SeerAutomationSource = SeerAutomationSource.ISSUE_DETAILS,
 ) -> tuple[dict[str, Any], int]:
     """
     Generate an AI summary for an issue.
@@ -361,6 +380,9 @@ def get_issue_summary(
     if not get_seer_org_acknowledgement(group.organization.id):
         return {"detail": "AI Autofix has not been acknowledged by the organization."}, 403
 
+    if not _has_seer_scanner_budget(group, source):
+        return {"detail": "No budget for Seer Scanner."}, 402
+
     cache_key = f"ai-group-summary-v2:{group.id}"
     lock_key = f"ai-group-summary-v2-lock:{group.id}"
     lock_duration = 10  # How long the lock is held if acquired (seconds)
@@ -371,6 +393,7 @@ def get_issue_summary(
         summary_dict, status_code = _generate_summary(
             group, user, force_event_id, source, cache_key
         )
+        _log_seer_scanner_billing_event(group, source)
         return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code
 
     # 1. Check cache first
@@ -392,6 +415,7 @@ def get_issue_summary(
             summary_dict, status_code = _generate_summary(
                 group, user, force_event_id, source, cache_key
             )
+            _log_seer_scanner_billing_event(group, source)
             return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code
 
     except UnableToAcquireLock:
diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py
index a572c6ce5c65a6..f788352c318d3e 100644
--- a/src/sentry/tasks/autofix.py
+++ b/src/sentry/tasks/autofix.py
@@ -1,7 +1,7 @@
 import logging
 from datetime import datetime, timedelta
 
-from sentry.autofix.utils import AutofixStatus, get_autofix_state
+from sentry.autofix.utils import AutofixStatus, SeerAutomationSource, get_autofix_state
 from sentry.models.group import Group
 from sentry.tasks.base import instrumented_task
 from sentry.taskworker.config import TaskworkerConfig
@@ -51,4 +51,4 @@ def start_seer_automation(group_id: int):
     from sentry.seer.issue_summary import get_issue_summary
 
     group = Group.objects.get(id=group_id)
-    get_issue_summary(group=group, source="post_process")
+    get_issue_summary(group=group, source=SeerAutomationSource.POST_PROCESS)
diff --git a/tests/sentry/api/endpoints/test_group_ai_summary.py b/tests/sentry/api/endpoints/test_group_ai_summary.py
index d1c6c73b75dd10..f2703bcdf39bbb 100644
--- a/tests/sentry/api/endpoints/test_group_ai_summary.py
+++ b/tests/sentry/api/endpoints/test_group_ai_summary.py
@@ -1,5 +1,6 @@
 from unittest.mock import ANY, patch
 
+from sentry.autofix.utils import SeerAutomationSource
 from sentry.testutils.cases import APITestCase, SnubaTestCase
 from sentry.testutils.helpers.features import apply_feature_flag_on_cls
 from sentry.testutils.skips import requires_snuba
@@ -28,7 +29,10 @@ def test_endpoint_calls_get_issue_summary(self, mock_get_issue_summary):
         assert response.status_code == 200
         assert response.data == mock_summary_data
         mock_get_issue_summary.assert_called_once_with(
-            group=self.group, user=ANY, force_event_id="test_event_id", source="issue_details"
+            group=self.group,
+            user=ANY,
+            force_event_id="test_event_id",
+            source=SeerAutomationSource.ISSUE_DETAILS,
         )
 
     @patch("sentry.api.endpoints.group_ai_summary.get_issue_summary")
@@ -41,7 +45,10 @@ def test_endpoint_without_event_id(self, mock_get_issue_summary):
         assert response.status_code == 200
         assert response.data == mock_summary_data
         mock_get_issue_summary.assert_called_once_with(
-            group=self.group, user=ANY, force_event_id=None, source="issue_details"
+            group=self.group,
+            user=ANY,
+            force_event_id=None,
+            source=SeerAutomationSource.ISSUE_DETAILS,
         )
 
     @patch("sentry.api.endpoints.group_ai_summary.get_issue_summary")
@@ -54,5 +61,8 @@ def test_endpoint_with_error_response(self, mock_get_issue_summary):
         assert response.status_code == 400
         assert response.data == error_data
         mock_get_issue_summary.assert_called_once_with(
-            group=self.group, user=ANY, force_event_id=None, source="issue_details"
+            group=self.group,
+            user=ANY,
+            force_event_id=None,
+            source=SeerAutomationSource.ISSUE_DETAILS,
         )
diff --git a/tests/sentry/api/endpoints/test_group_autofix_setup_check.py b/tests/sentry/api/endpoints/test_group_autofix_setup_check.py
index 1aabc6aa610c6c..7ca14bbbd3bb6c 100644
--- a/tests/sentry/api/endpoints/test_group_autofix_setup_check.py
+++ b/tests/sentry/api/endpoints/test_group_autofix_setup_check.py
@@ -55,6 +55,9 @@ def test_successful_setup(self):
                 "orgHasAcknowledged": False,
                 "userHasAcknowledged": False,
             },
+            "billing": {
+                "hasAutofixQuota": True,
+            },
         }
 
     def test_current_user_acknowledged_setup(self):
@@ -154,6 +157,9 @@ def test_successful_with_write_access(self, mock_get_repos_and_access):
                 "orgHasAcknowledged": False,
                 "userHasAcknowledged": False,
             },
+            "billing": {
+                "hasAutofixQuota": True,
+            },
         }
 
 
diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py
index ca8f27d17b8e0b..2b22d8d46836ea 100644
--- a/tests/sentry/integrations/slack/test_message_builder.py
+++ b/tests/sentry/integrations/slack/test_message_builder.py
@@ -8,6 +8,7 @@
 from django.urls import reverse
 from urllib3.response import HTTPResponse
 
+from sentry.autofix.utils import SeerAutomationSource
 from sentry.eventstore.models import Event
 from sentry.grouping.grouptype import ErrorGroupType
 from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL
@@ -995,7 +996,7 @@ def test_build_group_block_with_ai_summary_with_feature_flag(
 
             blocks = SlackIssuesMessageBuilder(group).build()
 
-            mock_get_summary.assert_called_once_with(group, source="alert")
+            mock_get_summary.assert_called_once_with(group, source=SeerAutomationSource.ALERT)
 
             # Verify that the original title is \\ present
             assert "IntegrationError" in blocks["blocks"][0]["text"]["text"]
diff --git a/tests/sentry/seer/test_issue_summary.py b/tests/sentry/seer/test_issue_summary.py
index 3a1a9b13484d7e..c696780c4fbb3a 100644
--- a/tests/sentry/seer/test_issue_summary.py
+++ b/tests/sentry/seer/test_issue_summary.py
@@ -4,6 +4,7 @@
 from unittest.mock import ANY, Mock, call, patch
 
 from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
+from sentry.autofix.utils import SeerAutomationSource
 from sentry.locks import locks
 from sentry.seer.issue_summary import (
     _get_event,
@@ -548,7 +549,9 @@ def test_run_automation_saves_fixability_score(
         self.group.refresh_from_db()
         assert self.group.seer_fixability_score is None
 
-        _run_automation(self.group, mock_user, mock_event, source="issue_details")
+        _run_automation(
+            self.group, mock_user, mock_event, source=SeerAutomationSource.ISSUE_DETAILS
+        )
 
         mock_generate_fixability_score.assert_called_once_with(self.group.id)
 
@@ -613,7 +616,9 @@ def test_is_issue_fixable_triggers_autofix(
 
             with self.subTest(option=option_value, score=score, should_trigger=should_trigger):
                 self.group.project.update_option("sentry:autofix_automation_tuning", option_value)
-                _run_automation(self.group, mock_user, mock_event, source="issue_details")
+                _run_automation(
+                    self.group, mock_user, mock_event, source=SeerAutomationSource.ISSUE_DETAILS
+                )
 
                 mock_generate_fixability_score.assert_called_once_with(self.group.id)
                 self.group.refresh_from_db()


Reviews:
user: login: roaga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:57:04Z
user: login: roaga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:21:14Z
user: login: jennmueng
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T05:53:45Z
user: login: roaga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T12:47:07Z

Review comments:
diff_hunk: @@ -37,7 +38,10 @@ def post(self, request: Request, group: Group) -> Response:
         force_event_id = data.get("event_id", None)
 
         summary_data, status_code = get_issue_summary(
-            group=group, user=request.user, force_event_id=force_event_id, source="issue_details"
+            group=group,
+            user=request.user,
+            force_event_id=force_event_id,
+            source=SeerAutomationSource.ISSUE_DETAILS,
path: src/sentry/api/endpoints/group_ai_summary.py
user: login: roaga
body: switching to an enum for cleanliness
created_at: 2025-05-20T15:57:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,5 +140,8 @@ def get(self, request: Request, group: Group) -> Response:
                     "orgHasAcknowledged": org_acknowledgement,
                     "userHasAcknowledged": user_acknowledgement,
                 },
+                "billing": {
+                    "hasAutofixQuota": has_autofix_quota,
+                },
             }
         )
path: src/sentry/api/endpoints/group_autofix_setup_check.py
user: login: roaga
body: The plan is to return billing and trial information in the setup check so we can display different things on the UI
created_at: 2025-05-20T16:21:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -808,6 +816,11 @@ def trigger_autofix(
 
     group.update(seer_autofix_last_triggered=timezone.now())
 
+    # log billing event for seer autofix
+    quotas.backend.record_seer_run(
path: src/sentry/seer/autofix.py
user: login: jennmueng
body: So, if we set it up like this does it mean that we count a seer run for each time you press start on autofix, including:
1. Hit start on a fresh issue without an autofix
2. Hit start over then start on an issue with an autofix
3. Automation that calls this method
but not if say, you do a rethink from the very beginning of an Autofix?
created_at: 2025-05-22T05:52:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -808,6 +816,11 @@ def trigger_autofix(
 
     group.update(seer_autofix_last_triggered=timezone.now())
 
+    # log billing event for seer autofix
+    quotas.backend.record_seer_run(
path: src/sentry/seer/autofix.py
user: login: roaga
body: Yep that's the intention
created_at: 2025-05-22T12:47:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-20T16:12:49Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry/pull/91937?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Attention: Patch coverage is `87.17949%` with `5 lines` in your changes missing coverage. Please review.

### :warning: Parser warning

> The parser emitted a warning. Please review your JUnit XML file:
> <pre><code class="language-python">Warning while parsing testcase attributes: Limit of string is 1000 chars, for name, we got 2083 at 1:156309 in /home/runner/work/sentry/sentry/.artifacts/pytest.junit.xml</code></pre>


| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry/pull/91937?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Patch % | Lines |
|---|---|---|
| [src/sentry/seer/issue\_summary.py](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&filepath=src%2Fsentry%2Fseer%2Fissue_summary.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS9zZWVyL2lzc3VlX3N1bW1hcnkucHk=) | 80.00% | [3 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |
| [src/sentry/seer/autofix.py](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&filepath=src%2Fsentry%2Fseer%2Fautofix.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS9zZWVyL2F1dG9maXgucHk=) | 83.33% | [1 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |
| [src/sentry/tasks/autofix.py](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&filepath=src%2Fsentry%2Ftasks%2Fautofix.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c3JjL3NlbnRyeS90YXNrcy9hdXRvZml4LnB5) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/getsentry/sentry/pull/91937?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master   #91937      +/-   ##
==========================================
+ Coverage   84.08%   87.87%   +3.79%     
==========================================
  Files       10170    10159      -11     
  Lines      582941   582614     -327     
  Branches    22603    22542      -61     
==========================================
+ Hits       490140   511977   +21837     
+ Misses      92348    70213   -22135     
+ Partials      453      424      -29     
```

</details>
author_association: NONE
user: login: getsentry-bot
created_at: 2025-05-22T16:33:00Z
body: PR reverted: 94d21bb22a119c16cb81ff252c2d348718c77f59
author_association: CONTRIBUTOR