Skip to content

Commit 465b4dd

Browse files
authored
Merge pull request #1542 from PolicyEngine/codex/add-analytics-routing-version
Track Modal routing version in calculate analytics
2 parents 92eede2 + 6297c84 commit 465b4dd

23 files changed

Lines changed: 941 additions & 10 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""add calculate analytics routing version
2+
3+
Revision ID: 20260519_0004
4+
Revises: 20260512_0003
5+
Create Date: 2026-05-19 22:05:42.821147
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
revision = "20260519_0004"
13+
down_revision = "20260512_0003"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
# ### commands auto generated by Alembic - please adjust! ###
20+
with op.batch_alter_table(
21+
"calculate_request_variables", schema=None
22+
) as batch_op:
23+
batch_op.add_column(
24+
sa.Column("requested_version", sa.String(length=64), nullable=True)
25+
)
26+
batch_op.add_column(
27+
sa.Column("resolved_channel", sa.String(length=16), nullable=True)
28+
)
29+
batch_op.create_index(
30+
"ix_calc_vars_channel_created",
31+
["resolved_channel", "created_at"],
32+
unique=False,
33+
)
34+
batch_op.create_index(
35+
"ix_calc_vars_requested_created",
36+
["requested_version", "created_at"],
37+
unique=False,
38+
)
39+
40+
with op.batch_alter_table("calculate_requests", schema=None) as batch_op:
41+
batch_op.add_column(
42+
sa.Column("requested_version", sa.String(length=64), nullable=True)
43+
)
44+
batch_op.add_column(
45+
sa.Column("resolved_channel", sa.String(length=16), nullable=True)
46+
)
47+
batch_op.create_index(
48+
"ix_calculate_requests_channel_created",
49+
["resolved_channel", "created_at"],
50+
unique=False,
51+
)
52+
batch_op.create_index(
53+
"ix_calculate_requests_requested_created",
54+
["requested_version", "created_at"],
55+
unique=False,
56+
)
57+
58+
# ### end Alembic commands ###
59+
60+
61+
def downgrade() -> None:
62+
# ### commands auto generated by Alembic - please adjust! ###
63+
with op.batch_alter_table("calculate_requests", schema=None) as batch_op:
64+
batch_op.drop_index("ix_calculate_requests_requested_created")
65+
batch_op.drop_index("ix_calculate_requests_channel_created")
66+
batch_op.drop_column("resolved_channel")
67+
batch_op.drop_column("requested_version")
68+
69+
with op.batch_alter_table(
70+
"calculate_request_variables", schema=None
71+
) as batch_op:
72+
batch_op.drop_index("ix_calc_vars_requested_created")
73+
batch_op.drop_index("ix_calc_vars_channel_created")
74+
batch_op.drop_column("resolved_channel")
75+
batch_op.drop_column("requested_version")
76+
77+
# ### end Alembic commands ###

changelog.d/1541.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added Modal requested-version and resolved-channel metadata to calculate analytics.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ANALYTICS_ALEMBIC_MINIMUM_REVISION = "20260512_0003"
1+
ANALYTICS_ALEMBIC_MINIMUM_REVISION = "20260519_0004"

policyengine_household_api/data/analytics_setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"api_version",
4747
"country_id",
4848
"model_version",
49+
"requested_version",
50+
"resolved_channel",
4951
"endpoint",
5052
"method",
5153
"content_length_bytes",
@@ -63,6 +65,8 @@
6365
"country_id",
6466
"api_version",
6567
"model_version",
68+
"requested_version",
69+
"resolved_channel",
6670
"response_status_code",
6771
"variable_name",
6872
"variable_name_truncated",

policyengine_household_api/data/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from policyengine_household_api.models.analytics import (
33
AnalyticsHttpMethod,
44
AvailabilityStatus,
5+
ModalResolvedChannel,
56
PeriodGranularity,
67
VariableSource,
78
)
@@ -52,6 +53,12 @@ class CalculateRequest(db.Model):
5253
api_version = mapped_column(String(32), nullable=True)
5354
country_id = mapped_column(String(16), nullable=False)
5455
model_version = mapped_column(String(64), nullable=True)
56+
requested_version = mapped_column(String(64), nullable=True)
57+
resolved_channel = mapped_column(
58+
String(16),
59+
nullable=True,
60+
info={"options": _enum_values(ModalResolvedChannel)},
61+
)
5562
endpoint = mapped_column(String(64), nullable=True)
5663
method = mapped_column(
5764
String(16),
@@ -79,6 +86,16 @@ class CalculateRequest(db.Model):
7986
"country_id",
8087
"created_at",
8188
),
89+
Index(
90+
"ix_calculate_requests_channel_created",
91+
"resolved_channel",
92+
"created_at",
93+
),
94+
Index(
95+
"ix_calculate_requests_requested_created",
96+
"requested_version",
97+
"created_at",
98+
),
8299
)
83100

84101

@@ -98,6 +115,12 @@ class CalculateRequestVariable(db.Model):
98115
country_id = mapped_column(String(16), nullable=False)
99116
api_version = mapped_column(String(32), nullable=True)
100117
model_version = mapped_column(String(64), nullable=True)
118+
requested_version = mapped_column(String(64), nullable=True)
119+
resolved_channel = mapped_column(
120+
String(16),
121+
nullable=True,
122+
info={"options": _enum_values(ModalResolvedChannel)},
123+
)
101124
response_status_code = mapped_column(Integer, nullable=True)
102125
variable_name = mapped_column(String(255), nullable=False)
103126
variable_name_truncated = mapped_column(
@@ -147,4 +170,14 @@ class CalculateRequestVariable(db.Model):
147170
"model_version",
148171
"variable_name",
149172
),
173+
Index(
174+
"ix_calc_vars_channel_created",
175+
"resolved_channel",
176+
"created_at",
177+
),
178+
Index(
179+
"ix_calc_vars_requested_created",
180+
"requested_version",
181+
"created_at",
182+
),
150183
)

policyengine_household_api/decorators/analytics.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@
2222
)
2323
from policyengine_household_api.models.analytics import (
2424
AnalyticsContext,
25+
ModalResolvedChannel,
2526
VariableUsageSummary,
2627
)
28+
from policyengine_household_api.utils.modal_routing_metadata import (
29+
REQUESTED_VERSION_ENVIRON_KEY,
30+
RESOLVED_CHANNEL_ENVIRON_KEY,
31+
)
2732
from policyengine_household_api.utils.variable_usage_analytics import (
2833
extract_variable_usage,
2934
stored_variable_name,
@@ -120,6 +125,7 @@ def _build_analytics_context(args, kwargs) -> AnalyticsContext:
120125
client_id = _client_id_from_request()
121126
country_id = _country_id_from_route_args(args, kwargs)
122127
payload = _request_json()
128+
requested_version, resolved_channel = _routing_metadata_from_request()
123129

124130
context = AnalyticsContext(
125131
client_id=client_id,
@@ -129,6 +135,8 @@ def _build_analytics_context(args, kwargs) -> AnalyticsContext:
129135
content_length_bytes=request.content_length,
130136
created_at=now,
131137
country_id=country_id,
138+
requested_version=requested_version,
139+
resolved_channel=resolved_channel,
132140
)
133141

134142
if country_id is None or not _collect_variable_usage():
@@ -199,6 +207,22 @@ def _collect_variable_usage() -> bool:
199207
return bool(value)
200208

201209

210+
def _routing_metadata_from_request() -> tuple[
211+
str | None, ModalResolvedChannel | None
212+
]:
213+
requested_version = request.environ.get(REQUESTED_VERSION_ENVIRON_KEY)
214+
resolved_channel = request.environ.get(RESOLVED_CHANNEL_ENVIRON_KEY)
215+
if not isinstance(requested_version, str) or not requested_version:
216+
return None, None
217+
218+
try:
219+
channel = ModalResolvedChannel(resolved_channel)
220+
except ValueError:
221+
return None, None
222+
223+
return requested_version, channel
224+
225+
202226
def _record_analytics(
203227
context: AnalyticsContext | None,
204228
response_status_code: int | None,
@@ -281,6 +305,10 @@ def _build_calculate_request(
281305
calculate_request.api_version = context.api_version
282306
calculate_request.country_id = context.country_id
283307
calculate_request.model_version = context.model_version
308+
calculate_request.requested_version = context.requested_version
309+
calculate_request.resolved_channel = (
310+
context.resolved_channel.value if context.resolved_channel else None
311+
)
284312
calculate_request.endpoint = context.endpoint
285313
calculate_request.method = context.method.value
286314
calculate_request.content_length_bytes = context.content_length_bytes
@@ -307,6 +335,8 @@ def _build_calculate_request_variable(
307335
variable.country_id = calculate_request.country_id
308336
variable.api_version = calculate_request.api_version
309337
variable.model_version = calculate_request.model_version
338+
variable.requested_version = calculate_request.requested_version
339+
variable.resolved_channel = calculate_request.resolved_channel
310340
variable.response_status_code = calculate_request.response_status_code
311341
(
312342
variable.variable_name,

policyengine_household_api/endpoints/analytics.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
CalculateRequest,
1717
CalculateRequestVariable,
1818
)
19+
from policyengine_household_api.models.analytics import ModalResolvedChannel
1920

2021

2122
DEFAULT_REQUEST_LIMIT = 1_000
@@ -28,6 +29,8 @@
2829
class CalculateAnalyticsQuery:
2930
start_time: datetime | None
3031
end_time: datetime | None
32+
requested_version: str | None
33+
resolved_channel: str | None
3134
unique: bool
3235
limit: int
3336

@@ -50,6 +53,8 @@ def get_calculate_analytics_requests() -> Response:
5053
"message": None,
5154
"start_time": _datetime_to_json(query.start_time),
5255
"end_time": _datetime_to_json(query.end_time),
56+
"requested_version": query.requested_version,
57+
"resolved_channel": query.resolved_channel,
5358
"unique": query.unique,
5459
}
5560
if query.unique:
@@ -97,6 +102,12 @@ def _parse_query_args() -> CalculateAnalyticsQuery:
97102
return CalculateAnalyticsQuery(
98103
start_time=start_time,
99104
end_time=end_time,
105+
requested_version=_parse_optional_string(
106+
request.args.get("requested_version")
107+
),
108+
resolved_channel=_parse_resolved_channel(
109+
request.args.get("resolved_channel")
110+
),
100111
unique=_parse_bool(request.args.get("unique"), default=False),
101112
limit=_parse_limit(request.args.get("limit")),
102113
)
@@ -139,6 +150,23 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
139150
raise ValueError("`unique` must be true or false")
140151

141152

153+
def _parse_optional_string(value: str | None) -> str | None:
154+
if value is None:
155+
return None
156+
value = value.strip()
157+
return value or None
158+
159+
160+
def _parse_resolved_channel(value: str | None) -> str | None:
161+
value = _parse_optional_string(value)
162+
if value is None:
163+
return None
164+
if value not in {channel.value for channel in ModalResolvedChannel}:
165+
allowed = ", ".join(channel.value for channel in ModalResolvedChannel)
166+
raise ValueError(f"`resolved_channel` must be one of: {allowed}")
167+
return value
168+
169+
142170
def _parse_limit(value: str | None) -> int:
143171
if value is None:
144172
return DEFAULT_REQUEST_LIMIT
@@ -156,7 +184,7 @@ def _parse_limit(value: str | None) -> int:
156184
def _calculate_requests(
157185
query: CalculateAnalyticsQuery,
158186
) -> list[dict[str, Any]]:
159-
request_query = _apply_time_filters(CalculateRequest.query, query)
187+
request_query = _apply_filters(CalculateRequest.query, query)
160188
calculate_requests = (
161189
request_query.order_by(CalculateRequest.created_at.desc())
162190
.limit(query.limit)
@@ -204,7 +232,7 @@ def _variables_by_request_id(
204232
def _unique_variable_keys(
205233
query: CalculateAnalyticsQuery,
206234
) -> list[dict[str, Any]]:
207-
variable_query = _apply_time_filters(CalculateRequestVariable.query, query)
235+
variable_query = _apply_filters(CalculateRequestVariable.query, query)
208236
rows = (
209237
variable_query.with_entities(
210238
CalculateRequestVariable.variable_name,
@@ -251,13 +279,21 @@ def _unique_variable_keys(
251279
]
252280

253281

254-
def _apply_time_filters(query, filters: CalculateAnalyticsQuery):
282+
def _apply_filters(query, filters: CalculateAnalyticsQuery):
255283
model = query.column_descriptions[0]["entity"]
256284
created_at = model.created_at
257285
if filters.start_time:
258286
query = query.filter(created_at >= filters.start_time)
259287
if filters.end_time:
260288
query = query.filter(created_at <= filters.end_time)
289+
if filters.requested_version:
290+
query = query.filter(
291+
model.requested_version == filters.requested_version
292+
)
293+
if filters.resolved_channel:
294+
query = query.filter(
295+
model.resolved_channel == filters.resolved_channel
296+
)
261297
return query
262298

263299

@@ -271,6 +307,8 @@ def _request_to_dict(
271307
"api_version": calculate_request.api_version,
272308
"country_id": calculate_request.country_id,
273309
"model_version": calculate_request.model_version,
310+
"requested_version": calculate_request.requested_version,
311+
"resolved_channel": calculate_request.resolved_channel,
274312
"endpoint": calculate_request.endpoint,
275313
"method": calculate_request.method,
276314
"response_status_code": calculate_request.response_status_code,

policyengine_household_api/modal_release/gateway.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
empty_manifest,
1414
validate_manifest,
1515
)
16+
from policyengine_household_api.utils.modal_routing_metadata import (
17+
MODAL_ROUTING_PAYLOAD_KEY,
18+
modal_routing_payload,
19+
)
1620

1721

1822
VERSIONED_ENDPOINTS = {"calculate", "calculate_demo"}
@@ -100,7 +104,7 @@ def route_request(path: str) -> Response:
100104
)
101105
return route_to_worker_function(
102106
resolved_app.app_name,
103-
_request_payload(path, body),
107+
_request_payload(path, body, resolved_app),
104108
)
105109
except GatewayResolutionError as e:
106110
return _json_error(str(e), 400)
@@ -212,13 +216,21 @@ def _country_and_endpoint(path: str) -> tuple[str | None, str | None]:
212216
return parts[0], parts[1]
213217

214218

215-
def _request_payload(path: str, body: bytes) -> dict[str, Any]:
219+
def _request_payload(
220+
path: str,
221+
body: bytes,
222+
resolved_app: ResolvedApp,
223+
) -> dict[str, Any]:
216224
return {
217225
"method": request.method,
218226
"path": path,
219227
"query_string": request.query_string.decode("utf-8"),
220228
"headers": _forward_headers(),
221229
"body": body,
230+
MODAL_ROUTING_PAYLOAD_KEY: modal_routing_payload(
231+
requested_version=resolved_app.requested_version,
232+
resolved_channel=resolved_app.channel,
233+
),
222234
}
223235

224236

0 commit comments

Comments
 (0)