Skip to content

Commit 930b78a

Browse files
authored
Merge branch 'master' into scttcper/ts6
2 parents 2a17c52 + 80a1773 commit 930b78a

File tree

24 files changed

+1487
-84
lines changed

24 files changed

+1487
-84
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
745745
/src/sentry/api/endpoints/check_am2_compatibility.py @getsentry/revenue
746746
/tests/js/getsentry-test/ @getsentry/revenue
747747
/src/sentry/billing/ @getsentry/revenue
748+
/tests/sentry/billing/ @getsentry/revenue
748749
/src/sentry/audit_log/ @getsentry/revenue
749750

750751
## gsApp

src/sentry/api/endpoints/api_application_rotate_secret.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class ApiApplicationRotateSecretEndpoint(ApiApplicationEndpoint):
1616
publish_status = {
1717
"POST": ApiPublishStatus.PRIVATE,
1818
}
19-
owner = ApiOwner.ENTERPRISE
19+
owner = ApiOwner.ECOSYSTEM
2020
authentication_classes = (SessionAuthentication,)
2121
permission_classes = (SentryIsAuthenticated,)
2222

src/sentry/api/endpoints/api_authorizations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class ApiAuthorizationsEndpoint(Endpoint):
2222
"DELETE": ApiPublishStatus.PRIVATE,
2323
"GET": ApiPublishStatus.PRIVATE,
2424
}
25-
owner = ApiOwner.ENTERPRISE
25+
owner = ApiOwner.ECOSYSTEM
2626
authentication_classes = (SessionAuthentication,)
2727
permission_classes = (SentryIsAuthenticated,)
2828

src/sentry/api/endpoints/organization_auth_token_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class OrganizationAuthTokenDetailsEndpoint(ControlSiloOrganizationEndpoint):
2323
"GET": ApiPublishStatus.PRIVATE,
2424
"PUT": ApiPublishStatus.PRIVATE,
2525
}
26-
owner = ApiOwner.ENTERPRISE
26+
owner = ApiOwner.ECOSYSTEM
2727
authentication_classes = (SessionNoAuthTokenAuthentication,)
2828
permission_classes = (OrgAuthTokenPermission, DisallowImpersonatedTokenCreation)
2929

src/sentry/api/endpoints/organization_auth_tokens.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class OrganizationAuthTokensEndpoint(ControlSiloOrganizationEndpoint):
4242
"GET": ApiPublishStatus.PRIVATE,
4343
"POST": ApiPublishStatus.PRIVATE,
4444
}
45-
owner = ApiOwner.ENTERPRISE
45+
owner = ApiOwner.ECOSYSTEM
4646
authentication_classes = (SessionNoAuthTokenAuthentication,)
4747
permission_classes = (OrgAuthTokenPermission, DisallowImpersonatedTokenCreation)
4848

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from sentry_protos.billing.v1.data_category_pb2 import DataCategory as ProtoDataCategory
6+
7+
from sentry.constants import DataCategory
8+
from sentry.utils import metrics
9+
10+
logger = logging.getLogger(__name__)
11+
12+
# Proto DataCategory enum uses different int values from Relay/Sentry DataCategory.
13+
# e.g., Proto ATTACHMENT=3 vs Relay ATTACHMENT=4.
14+
# ClickHouse stores Relay ints. The proto request carries proto ints.
15+
# This mapping converts between the two.
16+
PROTO_TO_RELAY_CATEGORY: dict[int, int] = {
17+
ProtoDataCategory.DATA_CATEGORY_ERROR: int(DataCategory.ERROR),
18+
ProtoDataCategory.DATA_CATEGORY_TRANSACTION: int(DataCategory.TRANSACTION),
19+
ProtoDataCategory.DATA_CATEGORY_ATTACHMENT: int(DataCategory.ATTACHMENT),
20+
ProtoDataCategory.DATA_CATEGORY_PROFILE: int(DataCategory.PROFILE),
21+
ProtoDataCategory.DATA_CATEGORY_REPLAY: int(DataCategory.REPLAY),
22+
ProtoDataCategory.DATA_CATEGORY_MONITOR: int(DataCategory.MONITOR),
23+
ProtoDataCategory.DATA_CATEGORY_SPAN: int(DataCategory.SPAN),
24+
ProtoDataCategory.DATA_CATEGORY_USER_REPORT_V2: int(DataCategory.USER_REPORT_V2),
25+
ProtoDataCategory.DATA_CATEGORY_PROFILE_DURATION: int(DataCategory.PROFILE_DURATION),
26+
ProtoDataCategory.DATA_CATEGORY_LOG_BYTE: int(DataCategory.LOG_BYTE),
27+
ProtoDataCategory.DATA_CATEGORY_PROFILE_DURATION_UI: int(DataCategory.PROFILE_DURATION_UI),
28+
ProtoDataCategory.DATA_CATEGORY_SEER_AUTOFIX: int(DataCategory.SEER_AUTOFIX),
29+
ProtoDataCategory.DATA_CATEGORY_SEER_SCANNER: int(DataCategory.SEER_SCANNER),
30+
ProtoDataCategory.DATA_CATEGORY_SIZE_ANALYSIS: int(DataCategory.SIZE_ANALYSIS),
31+
ProtoDataCategory.DATA_CATEGORY_INSTALLABLE_BUILD: int(DataCategory.INSTALLABLE_BUILD),
32+
ProtoDataCategory.DATA_CATEGORY_TRACE_METRIC: int(DataCategory.TRACE_METRIC),
33+
ProtoDataCategory.DATA_CATEGORY_DEFAULT: int(DataCategory.DEFAULT),
34+
ProtoDataCategory.DATA_CATEGORY_SECURITY: int(DataCategory.SECURITY),
35+
ProtoDataCategory.DATA_CATEGORY_PROFILE_CHUNK: int(DataCategory.PROFILE_CHUNK),
36+
ProtoDataCategory.DATA_CATEGORY_PROFILE_CHUNK_UI: int(DataCategory.PROFILE_CHUNK_UI),
37+
}
38+
39+
40+
def proto_to_relay_category(proto_category: int) -> int:
41+
"""Convert a proto DataCategory int to the Relay/Sentry int used in ClickHouse."""
42+
result = PROTO_TO_RELAY_CATEGORY.get(proto_category)
43+
if result is None:
44+
metrics.incr(
45+
"billing.proto_category_mapping.unmapped",
46+
tags={"proto_category": str(proto_category)},
47+
sample_rate=1.0,
48+
)
49+
return proto_category
50+
return result
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections import defaultdict
5+
from collections.abc import Sequence
6+
from datetime import datetime, timedelta, timezone
7+
8+
from google.protobuf.timestamp_pb2 import Timestamp
9+
from sentry_protos.billing.v1.date_pb2 import Date
10+
from sentry_protos.billing.v1.services.usage.v1.endpoint_usage_pb2 import (
11+
CategoryUsage,
12+
DailyUsage,
13+
GetUsageRequest,
14+
GetUsageResponse,
15+
)
16+
from sentry_protos.billing.v1.usage_data_pb2 import UsageData
17+
from snuba_sdk import (
18+
Column,
19+
Condition,
20+
Entity,
21+
Function,
22+
Granularity,
23+
Limit,
24+
Op,
25+
OrderBy,
26+
Query,
27+
Request,
28+
)
29+
from snuba_sdk.orderby import Direction
30+
31+
from sentry.billing.platform.services.usage._category_mapping import proto_to_relay_category
32+
from sentry.snuba.referrer import Referrer
33+
from sentry.utils import metrics
34+
from sentry.utils.outcomes import Outcome
35+
from sentry.utils.snuba import raw_snql_query
36+
37+
logger = logging.getLogger(__name__)
38+
39+
_REFERRER = Referrer.BILLING_USAGE_SERVICE_CLICKHOUSE.value
40+
_APP_ID = "billing"
41+
_DATASET = "outcomes"
42+
_DAILY_GRANULARITY = 86400
43+
_QUERY_LIMIT = 10000
44+
45+
# Outcomes stored in PG BillingMetricUsage (getsentry outcomes consumer
46+
# filters to these three at ingest). The CH outcomes table also has
47+
# INVALID, ABUSE, CLIENT_DISCARD, and CARDINALITY_LIMITED.
48+
_BILLABLE_OUTCOMES = [Outcome.ACCEPTED, Outcome.FILTERED, Outcome.RATE_LIMITED]
49+
50+
51+
def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse:
52+
org_id = request.organization_id
53+
start = _timestamp_to_datetime(request.start)
54+
# The proto contract defines `end` as inclusive (midnight of the last
55+
# included day). Snuba queries use a half-open interval [start, end),
56+
# so we add one day to convert inclusive→exclusive. Without this, all
57+
# hourly rows on the last day would be excluded.
58+
end = _timestamp_to_datetime(request.end) + timedelta(days=1)
59+
# Proto categories use different int values from Relay/ClickHouse
60+
# (e.g., proto ATTACHMENT=3 vs Relay ATTACHMENT=4). Convert before querying.
61+
categories = [proto_to_relay_category(c) for c in request.categories]
62+
63+
snuba_request = _build_query(org_id, start, end, categories, total_outcomes=_BILLABLE_OUTCOMES)
64+
result = raw_snql_query(snuba_request, referrer=_REFERRER)
65+
rows = result["data"]
66+
67+
if len(rows) >= _QUERY_LIMIT:
68+
logger.warning(
69+
"billing.usage_query.truncated",
70+
extra={"org_id": org_id, "row_count": len(rows)},
71+
)
72+
metrics.incr(
73+
"billing.usage_query.truncated",
74+
tags={"org_id": str(org_id)},
75+
sample_rate=1.0,
76+
)
77+
78+
return _build_response(rows)
79+
80+
81+
def _build_query(
82+
org_id: int,
83+
start: datetime,
84+
end: datetime,
85+
categories: Sequence[int],
86+
*,
87+
total_outcomes: Sequence[int] | None = None,
88+
) -> Request:
89+
# Half-open interval [start, end) — standard sentry.snuba.outcomes convention.
90+
# `end` has already been shifted +1 day in query_outcomes_usage() to convert
91+
# the proto's inclusive end into the exclusive boundary Snuba expects.
92+
where = [
93+
Condition(Column("org_id"), Op.EQ, org_id),
94+
Condition(Column("timestamp"), Op.GTE, start),
95+
Condition(Column("timestamp"), Op.LT, end),
96+
]
97+
if categories:
98+
where.append(Condition(Column("category"), Op.IN, categories))
99+
100+
query = Query(
101+
match=Entity("outcomes"),
102+
select=[
103+
Column("category"),
104+
Column("time"),
105+
_total_function(total_outcomes),
106+
Function(
107+
"sumIf",
108+
[Column("quantity"), Function("equals", [Column("outcome"), Outcome.ACCEPTED])],
109+
"accepted",
110+
),
111+
Function(
112+
"sumIf",
113+
[
114+
Column("quantity"),
115+
Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]),
116+
],
117+
"dropped",
118+
),
119+
Function(
120+
"sumIf",
121+
[Column("quantity"), Function("equals", [Column("outcome"), Outcome.FILTERED])],
122+
"filtered",
123+
),
124+
Function("sumIf", [Column("quantity"), _over_quota_condition()], "over_quota"),
125+
Function(
126+
"sumIf",
127+
[
128+
Column("quantity"),
129+
Function(
130+
"and",
131+
[
132+
Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]),
133+
Function("equals", [Column("reason"), "smart_rate_limit"]),
134+
],
135+
),
136+
],
137+
"spike_protection",
138+
),
139+
Function(
140+
"sumIf",
141+
[
142+
Column("quantity"),
143+
Function(
144+
"and",
145+
[
146+
Function("equals", [Column("outcome"), Outcome.FILTERED]),
147+
Function("startsWith", [Column("reason"), "Sampled:"]),
148+
],
149+
),
150+
],
151+
"dynamic_sampling",
152+
),
153+
],
154+
groupby=[Column("category"), Column("time")],
155+
where=where,
156+
orderby=[OrderBy(Column("time"), Direction.ASC)],
157+
granularity=Granularity(_DAILY_GRANULARITY),
158+
limit=Limit(_QUERY_LIMIT),
159+
)
160+
return Request(
161+
dataset=_DATASET,
162+
app_id=_APP_ID,
163+
query=query,
164+
tenant_ids={"organization_id": org_id},
165+
)
166+
167+
168+
def _build_response(rows: list[dict]) -> GetUsageResponse:
169+
# Two-level accumulator: days_map[day_str][category_id] -> usage fields.
170+
# Each row already contains all 7 sumIf-aggregated fields from ClickHouse.
171+
#
172+
# NOTE: CategoryUsage.category carries Relay/Sentry int values (not proto
173+
# DataCategory ints). The proto field is typed as DataCategory but every
174+
# existing consumer (getsentry postgres backend, shadow comparison,
175+
# UsagePricerService, customer_usage, projection, etc.) interprets it as a
176+
# Relay int. Converting to proto ints here would break all consumers and
177+
# the shadow comparison. See the TODO in getsentry's
178+
# usage_pricer/service.py for the planned migration.
179+
days_map: defaultdict[str, dict[int, dict[str, int]]] = defaultdict(dict)
180+
181+
for row in rows:
182+
day = row["time"]
183+
category = int(row["category"])
184+
days_map[day][category] = {
185+
"total": int(row["total"]),
186+
"accepted": int(row["accepted"]),
187+
"dropped": int(row["dropped"]),
188+
"filtered": int(row["filtered"]),
189+
"over_quota": int(row["over_quota"]),
190+
"spike_protection": int(row["spike_protection"]),
191+
"dynamic_sampling": int(row["dynamic_sampling"]),
192+
}
193+
194+
days = []
195+
for day_str in sorted(days_map):
196+
date = _parse_day(day_str)
197+
usage = [
198+
CategoryUsage(category=cat, data=UsageData(**fields)) # type: ignore[arg-type]
199+
for cat, fields in sorted(days_map[day_str].items())
200+
]
201+
days.append(DailyUsage(date=date, usage=usage))
202+
203+
return GetUsageResponse(days=days, seats=[])
204+
205+
206+
def _total_function(outcomes: Sequence[int] | None) -> Function:
207+
"""Build the ``total`` aggregate.
208+
209+
When *outcomes* is provided, only those outcome types are counted
210+
(billing callers pass ``_BILLABLE_OUTCOMES``). When ``None``, every
211+
outcome is counted (useful for general-purpose usage queries).
212+
"""
213+
if outcomes is None:
214+
return Function("sum", [Column("quantity")], "total")
215+
return Function(
216+
"sumIf",
217+
[
218+
Column("quantity"),
219+
Function(
220+
"in",
221+
[
222+
Column("outcome"),
223+
Function("tuple", list(outcomes)),
224+
],
225+
),
226+
],
227+
"total",
228+
)
229+
230+
231+
def _over_quota_condition() -> Function:
232+
"""ClickHouse condition for over-quota rate limiting.
233+
234+
Matches: outcome=RATE_LIMITED AND (reason ends with "_usage_exceeded"
235+
OR reason="usage_exceeded" OR reason="grace_period").
236+
"""
237+
return Function(
238+
"and",
239+
[
240+
Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]),
241+
Function(
242+
"or",
243+
[
244+
Function("endsWith", [Column("reason"), "_usage_exceeded"]),
245+
Function(
246+
"or",
247+
[
248+
Function("equals", [Column("reason"), "usage_exceeded"]),
249+
Function("equals", [Column("reason"), "grace_period"]),
250+
],
251+
),
252+
],
253+
),
254+
],
255+
)
256+
257+
258+
def _timestamp_to_datetime(ts: Timestamp) -> datetime:
259+
return ts.ToDatetime(tzinfo=timezone.utc)
260+
261+
262+
def _parse_day(value: str) -> Date:
263+
dt = datetime.fromisoformat(value)
264+
return Date(year=dt.year, month=dt.month, day=dt.day)

src/sentry/billing/platform/services/usage/service.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
)
77

88
from sentry.billing.platform.core import BillingService, service_method
9+
from sentry.billing.platform.services.usage._outcomes_query import query_outcomes_usage
910

1011

1112
class UsageService(BillingService):
@@ -18,6 +19,4 @@ def get_usage(self, request: GetUsageRequest) -> GetUsageResponse:
1819
accepted, dropped, filtered, over_quota, spike_protection, and
1920
dynamic_sampling.
2021
"""
21-
# Default implementation returns empty response.
22-
# GetSentry overrides this with Postgres/ClickHouse backends.
23-
return GetUsageResponse()
22+
return query_outcomes_usage(request)

src/sentry/data_secrecy/api/waive_data_secrecy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class WaiveDataSecrecyEndpoint(ControlSiloOrganizationEndpoint):
5353
"POST": ApiPublishStatus.PRIVATE,
5454
"DELETE": ApiPublishStatus.PRIVATE,
5555
}
56-
owner = ApiOwner.ENTERPRISE
56+
owner = ApiOwner.ECOSYSTEM
5757

5858
def get(
5959
self,

0 commit comments

Comments
 (0)