Skip to content

Commit cd4f166

Browse files
khvn26matthewelwellpre-commit-ci[bot]
authored
feat: Backend for SDK metrics (#5623)
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent b566ff6 commit cd4f166

35 files changed

Lines changed: 1759 additions & 728 deletions

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"django.contrib.contenttypes",
8080
"django.contrib.sessions",
8181
"django.contrib.messages",
82+
"django.contrib.postgres",
8283
"django.contrib.staticfiles",
8384
"django.contrib.humanize",
8485
"rest_framework",
Lines changed: 130 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from datetime import date, datetime, timedelta
2-
from typing import TYPE_CHECKING, List
32

3+
import structlog
44
from dateutil.relativedelta import relativedelta
55
from django.conf import settings
6-
from django.db.models import Sum
6+
from django.db.models import Q, Sum
77
from django.utils import timezone
88
from rest_framework.exceptions import NotFound
99

10+
from app_analytics import constants
1011
from app_analytics.dataclasses import FeatureEvaluationData, UsageData
1112
from app_analytics.influxdb_wrapper import (
1213
get_events_for_organisation,
@@ -17,86 +18,60 @@
1718
from app_analytics.influxdb_wrapper import (
1819
get_usage_data as get_usage_data_from_influxdb,
1920
)
21+
from app_analytics.mappers import map_annotated_api_usage_buckets_to_usage_data
2022
from app_analytics.models import (
2123
APIUsageBucket,
2224
FeatureEvaluationBucket,
23-
Resource,
2425
)
26+
from app_analytics.types import Labels, PeriodType
2527
from environments.models import Environment
2628
from features.models import Feature
27-
from organisations.models import Organisation
29+
from organisations.models import Organisation, OrganisationSubscriptionInformationCache
2830

29-
from . import constants
30-
from .types import PERIOD_TYPE
31+
logger = structlog.get_logger("app_analytics")
3132

3233

3334
def get_usage_data(
3435
organisation: Organisation,
3536
environment_id: int | None = None,
3637
project_id: int | None = None,
37-
period: PERIOD_TYPE | None = None,
38+
period: PeriodType | None = None,
39+
labels_filter: Labels | None = None,
3840
) -> list[UsageData]:
39-
now = timezone.now()
40-
date_start = date_stop = None
41-
sub_cache = getattr(organisation, "subscription_information_cache", None)
41+
sub_cache = OrganisationSubscriptionInformationCache.objects.filter(
42+
organisation=organisation
43+
).first()
4244

43-
is_subscription_valid = (
44-
sub_cache is not None and sub_cache.is_billing_terms_dates_set()
45+
date_start, date_stop = _get_start_date_and_stop_date_for_subscribed_organisation(
46+
sub_cache=sub_cache,
47+
period=period,
4548
)
4649

47-
if period in (constants.CURRENT_BILLING_PERIOD, constants.PREVIOUS_BILLING_PERIOD):
48-
if not is_subscription_valid:
49-
raise NotFound("No billing periods found for this organisation.")
50-
51-
if TYPE_CHECKING:
52-
assert sub_cache is not None
53-
54-
match period:
55-
case constants.CURRENT_BILLING_PERIOD:
56-
starts_at = sub_cache.current_billing_term_starts_at or now - timedelta(
57-
days=30
58-
)
59-
month_delta = relativedelta(now, starts_at).months
60-
date_start = relativedelta(months=month_delta) + starts_at
61-
date_stop = now
62-
63-
case constants.PREVIOUS_BILLING_PERIOD:
64-
starts_at = sub_cache.current_billing_term_starts_at or now - timedelta(
65-
days=30
66-
)
67-
month_delta = relativedelta(now, starts_at).months - 1
68-
month_delta += relativedelta(now, starts_at).years * 12
69-
date_start = relativedelta(months=month_delta) + starts_at
70-
date_stop = relativedelta(months=month_delta + 1) + starts_at
71-
case constants.NINETY_DAY_PERIOD:
72-
date_start = now - relativedelta(days=90)
73-
date_stop = now
74-
7550
if settings.USE_POSTGRES_FOR_ANALYTICS:
76-
kwargs = {
77-
"organisation": organisation,
78-
"environment_id": environment_id,
79-
"project_id": project_id,
80-
}
81-
if date_start:
82-
assert date_stop
83-
kwargs["date_start"] = date_start # type: ignore[assignment]
84-
kwargs["date_stop"] = date_stop # type: ignore[assignment]
85-
86-
return get_usage_data_from_local_db(**kwargs) # type: ignore[arg-type]
87-
88-
kwargs = {
89-
"organisation_id": organisation.id,
90-
"environment_id": environment_id,
91-
"project_id": project_id,
92-
}
51+
return get_usage_data_from_local_db(
52+
organisation=organisation,
53+
environment_id=environment_id,
54+
project_id=project_id,
55+
date_start=date_start,
56+
date_stop=date_stop,
57+
labels_filter=labels_filter,
58+
)
9359

94-
if date_start:
95-
assert date_stop
96-
kwargs["date_start"] = date_start # type: ignore[assignment]
97-
kwargs["date_stop"] = date_stop # type: ignore[assignment]
60+
if settings.INFLUXDB_TOKEN:
61+
return get_usage_data_from_influxdb(
62+
organisation_id=organisation.id,
63+
environment_id=environment_id,
64+
project_id=project_id,
65+
date_start=date_start,
66+
date_stop=date_stop,
67+
labels_filter=labels_filter,
68+
)
9869

99-
return get_usage_data_from_influxdb(**kwargs) # type: ignore[arg-type]
70+
logger.warning(
71+
"no-analytics-database-configured",
72+
details=constants.NO_ANALYTICS_DATABASE_CONFIGURED_WARNING,
73+
)
74+
return []
10075

10176

10277
def get_usage_data_from_local_db(
@@ -105,7 +80,8 @@ def get_usage_data_from_local_db(
10580
project_id: int | None = None,
10681
date_start: datetime | None = None,
10782
date_stop: datetime | None = None,
108-
) -> List[UsageData]:
83+
labels_filter: Labels | None = None,
84+
) -> list[UsageData]:
10985
if date_start is None:
11086
date_start = timezone.now() - timedelta(days=30)
11187
if date_stop is None:
@@ -127,28 +103,20 @@ def get_usage_data_from_local_db(
127103
if environment_id:
128104
qs = qs.filter(environment_id=environment_id)
129105

106+
if labels_filter:
107+
qs = qs.filter(labels__contains=labels_filter)
108+
130109
qs = (
131110
qs.filter( # type: ignore[assignment]
132111
created_at__date__lte=date_stop,
133112
created_at__date__gt=date_start,
134113
)
135114
.order_by("created_at__date")
136-
.values("created_at__date", "resource")
115+
.values("created_at__date", "resource", "labels")
137116
.annotate(count=Sum("total_count"))
138117
)
139-
data_by_day = {}
140-
for row in qs: # TODO Write proper mappers for this?
141-
day = row["created_at__date"]
142-
if day not in data_by_day:
143-
data_by_day[day] = UsageData(day=day)
144-
if column_name := Resource(row["resource"]).column_name:
145-
setattr(
146-
data_by_day[day],
147-
column_name,
148-
row["count"],
149-
)
150118

151-
return data_by_day.values() # type: ignore[return-value]
119+
return map_annotated_api_usage_buckets_to_usage_data(qs)
152120

153121

154122
def get_total_events_count(organisation) -> int: # type: ignore[no-untyped-def]
@@ -168,30 +136,53 @@ def get_total_events_count(organisation) -> int: # type: ignore[no-untyped-def]
168136

169137

170138
def get_feature_evaluation_data(
171-
feature: Feature, environment_id: int, period: int = 30
172-
) -> List[FeatureEvaluationData]:
139+
feature: Feature,
140+
environment_id: int,
141+
period_days: int = 30,
142+
labels_filter: Labels | None = None,
143+
) -> list[FeatureEvaluationData]:
173144
if settings.USE_POSTGRES_FOR_ANALYTICS:
174145
return get_feature_evaluation_data_from_local_db(
175-
feature, environment_id, period
146+
feature=feature,
147+
environment_id=environment_id,
148+
period_days=period_days,
149+
labels_filter=labels_filter,
176150
)
177-
return get_feature_evaluation_data_from_influxdb(
178-
feature_name=feature.name, environment_id=environment_id, period=f"{period}d"
151+
152+
if settings.INFLUXDB_TOKEN:
153+
return get_feature_evaluation_data_from_influxdb(
154+
feature_name=feature.name,
155+
environment_id=environment_id,
156+
period_days=period_days,
157+
labels_filter=labels_filter,
158+
)
159+
160+
logger.warning(
161+
"no-analytics-database-configured",
162+
details=constants.NO_ANALYTICS_DATABASE_CONFIGURED_WARNING,
179163
)
164+
return []
180165

181166

182167
def get_feature_evaluation_data_from_local_db(
183-
feature: Feature, environment_id: int, period: int = 30
184-
) -> List[FeatureEvaluationData]:
168+
feature: Feature,
169+
environment_id: int,
170+
period_days: int = 30,
171+
labels_filter: Labels | None = None,
172+
) -> list[FeatureEvaluationData]:
173+
filter = Q(
174+
environment_id=environment_id,
175+
bucket_size=constants.ANALYTICS_READ_BUCKET_SIZE,
176+
feature_name=feature.name,
177+
created_at__date__lte=timezone.now(),
178+
created_at__date__gt=timezone.now() - timedelta(days=period_days),
179+
)
180+
if labels_filter:
181+
filter &= Q(labels__contains=labels_filter)
185182
feature_evaluation_data = (
186-
FeatureEvaluationBucket.objects.filter(
187-
environment_id=environment_id,
188-
bucket_size=constants.ANALYTICS_READ_BUCKET_SIZE,
189-
feature_name=feature.name,
190-
created_at__date__lte=timezone.now(),
191-
created_at__date__gt=timezone.now() - timedelta(days=period),
192-
)
183+
FeatureEvaluationBucket.objects.filter(filter)
193184
.order_by("created_at__date")
194-
.values("created_at__date", "feature_name", "environment_id")
185+
.values("created_at__date", "feature_name", "environment_id", "labels")
195186
.annotate(count=Sum("total_count"))
196187
)
197188
usage_list = []
@@ -200,15 +191,61 @@ def get_feature_evaluation_data_from_local_db(
200191
FeatureEvaluationData(
201192
day=data["created_at__date"],
202193
count=data["count"],
194+
labels=data["labels"],
203195
)
204196
)
205197
return usage_list
206198

207199

208-
def _get_environment_ids_for_org(organisation) -> List[int]: # type: ignore[no-untyped-def]
200+
def _get_environment_ids_for_org(organisation: Organisation) -> list[int]:
209201
# We need to do this to prevent Django from generating a query that
210202
# references the environments and projects tables,
211203
# as they do not exist in the analytics database.
212204
return [
213-
e.id for e in Environment.objects.filter(project__organisation=organisation)
205+
*Environment.objects.filter(
206+
project__organisation=organisation,
207+
).values_list(
208+
"id",
209+
flat=True,
210+
)
214211
]
212+
213+
214+
def _get_start_date_and_stop_date_for_subscribed_organisation(
215+
sub_cache: OrganisationSubscriptionInformationCache | None,
216+
period: PeriodType | None = None,
217+
) -> tuple[datetime | None, datetime | None]:
218+
"""
219+
Populate start and stop date for the given period
220+
from the organisation's subscription information.
221+
"""
222+
now = timezone.now()
223+
224+
match period:
225+
case constants.CURRENT_BILLING_PERIOD:
226+
if sub_cache and sub_cache.current_billing_term_starts_at:
227+
starts_at = sub_cache.current_billing_term_starts_at
228+
else:
229+
raise NotFound("No billing periods found for this organisation.")
230+
231+
month_delta = relativedelta(now, starts_at).months
232+
date_start = relativedelta(months=month_delta) + starts_at
233+
return date_start, now
234+
235+
case constants.PREVIOUS_BILLING_PERIOD:
236+
if sub_cache and sub_cache.current_billing_term_starts_at:
237+
starts_at = sub_cache.current_billing_term_starts_at
238+
else:
239+
raise NotFound("No billing periods found for this organisation.")
240+
241+
month_delta = relativedelta(now, starts_at).months - 1
242+
month_delta += relativedelta(now, starts_at).years * 12
243+
date_start = relativedelta(months=month_delta) + starts_at
244+
date_stop = relativedelta(months=month_delta + 1) + starts_at
245+
return date_start, date_stop
246+
247+
case constants.NINETY_DAY_PERIOD:
248+
date_start = now - relativedelta(days=90)
249+
return date_start, now
250+
251+
return None, None

0 commit comments

Comments
 (0)