From 2f7671eb8fe0ecba9c7c1aebbb7ef7c25ad777cd Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 5 Sep 2026 08:06:28 +0530 Subject: [PATCH] feat: keep host on aggregated API usage buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host was written to APIUsageRaw and then dropped by the hourly rollup, so it only survived the raw-data retention window. Buckets now carry it: the rollup groups by host and the overlap check treats it as a dimension. Read paths are unchanged — aggregations still sum across hosts — so dashboards behave exactly as before while the data stays available. --- .../migrations/0009_apiusagebucket_host.py | 18 ++++++++++++ api/app_analytics/models.py | 3 +- api/app_analytics/tasks.py | 5 ++-- api/tests/unit/app_analytics/test_tasks.py | 28 +++++++++++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 api/app_analytics/migrations/0009_apiusagebucket_host.py diff --git a/api/app_analytics/migrations/0009_apiusagebucket_host.py b/api/app_analytics/migrations/0009_apiusagebucket_host.py new file mode 100644 index 000000000000..6ff4e62725f3 --- /dev/null +++ b/api/app_analytics/migrations/0009_apiusagebucket_host.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-05 02:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("app_analytics", "0008_labels_jsonb"), + ] + + operations = [ + migrations.AddField( + model_name="apiusagebucket", + name="host", + field=models.CharField(default="", max_length=255), + ), + ] diff --git a/api/app_analytics/models.py b/api/app_analytics/models.py index 869762161692..1ceb829ad309 100644 --- a/api/app_analytics/models.py +++ b/api/app_analytics/models.py @@ -94,10 +94,11 @@ def check_overlapping_buckets(self, filters): # type: ignore[no-untyped-def] class APIUsageBucket(AbstractBucket): resource = models.IntegerField(choices=Resource.choices) + host = models.CharField(max_length=255, default="") @hook(BEFORE_CREATE) def check_overlapping_buckets(self): # type: ignore[no-untyped-def] - filter = models.Q(resource=self.resource) + filter = models.Q(resource=self.resource, host=self.host) super().check_overlapping_buckets(filter) # type: ignore[no-untyped-call] diff --git a/api/app_analytics/tasks.py b/api/app_analytics/tasks.py index b0a5b4a1b7d2..6db7c387d85c 100644 --- a/api/app_analytics/tasks.py +++ b/api/app_analytics/tasks.py @@ -191,6 +191,7 @@ def populate_api_usage_bucket( defaults={"total_count": row["count"]}, environment_id=row["environment_id"], resource=row["resource"], + host=row["host"], bucket_size=bucket_size, created_at=bucket_start_time, labels=row["labels"], @@ -229,12 +230,12 @@ def _get_api_usage_source_data( if source_bucket_size: return ( APIUsageBucket.objects.filter(filters, bucket_size=source_bucket_size) - .values("environment_id", "resource", "labels") + .values("environment_id", "resource", "host", "labels") .annotate(count=Sum("total_count")) ) return ( APIUsageRaw.objects.filter(filters) - .values("environment_id", "resource", "labels") + .values("environment_id", "resource", "host", "labels") .annotate( count=Sum("count"), ) diff --git a/api/tests/unit/app_analytics/test_tasks.py b/api/tests/unit/app_analytics/test_tasks.py index be3db9212e59..45dd797ba7d8 100644 --- a/api/tests/unit/app_analytics/test_tasks.py +++ b/api/tests/unit/app_analytics/test_tasks.py @@ -26,10 +26,12 @@ pytestmark = pytest.mark.use_analytics_db -def _create_api_usage_event(environment_id: int, when: datetime) -> APIUsageRaw: +def _create_api_usage_event( + environment_id: int, when: datetime, host: str = "host1" +) -> APIUsageRaw: event = APIUsageRaw.objects.create( environment_id=environment_id, - host="host1", + host=host, resource=Resource.FLAGS, ) # update created_at @@ -534,6 +536,28 @@ def test_populate_api_usage_bucket__source_bucket_size__aggregates_correctly( assert APIUsageBucket.objects.filter(bucket_size=15, total_count=300).count() == 1 +def test_populate_api_usage_bucket__multiple_hosts__preserves_host( + freezer: FrozenDateTimeFactory, +) -> None: + # Given events from two hosts in the same bucket window + environment_id = 1 + when = timezone.now() - timedelta(minutes=90) + for _ in range(3): + _create_api_usage_event(environment_id, when, host="edge-proxy") + _create_api_usage_event(environment_id, when) + + # When + freezer.move_to(timezone.now() - timedelta(hours=1)) + populate_api_usage_bucket(bucket_size=15, run_every=60) + + # Then the buckets are split by host, each keeping its host + buckets = APIUsageBucket.objects.filter(environment_id=environment_id) + assert {(bucket.host, bucket.total_count) for bucket in buckets} == { + ("edge-proxy", 3), + ("host1", 1), + } + + def _create_feature_evaluation_event( environment_id: int, feature_name: str,