policyengine-household-analytics 0.29.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- policyengine_household_analytics-0.29.6/.gitignore +16 -0
- policyengine_household_analytics-0.29.6/PKG-INFO +27 -0
- policyengine_household_analytics-0.29.6/README.md +12 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/__init__.py +0 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/env.py +90 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/script.py.mako +24 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/versions/20260508_0001_existing_visits_baseline.py +32 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/versions/20260508_0002_calculate_variable_usage.py +143 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/versions/20260512_0003_cap_analytics_variable_names.py +76 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/versions/20260519_0004_calculate_analytics_routing_version.py +77 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/analytics_setup.py +433 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/events.py +17 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/orm.py +183 -0
- policyengine_household_analytics-0.29.6/policyengine_household_analytics/persistence.py +177 -0
- policyengine_household_analytics-0.29.6/pyproject.toml +32 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
**/__pycache__
|
|
2
|
+
*.egg-info
|
|
3
|
+
.pytest_cache
|
|
4
|
+
.mypy_cache
|
|
5
|
+
.vscode
|
|
6
|
+
**/*.db
|
|
7
|
+
**/*.db-journal
|
|
8
|
+
dist/*
|
|
9
|
+
**/*.rdb
|
|
10
|
+
**/*.h5
|
|
11
|
+
**/*.csv.gz
|
|
12
|
+
.env
|
|
13
|
+
.ds_store
|
|
14
|
+
|
|
15
|
+
# Generated by modal-deploy-release.sh for Modal image builds
|
|
16
|
+
requirements-modal-*.txt
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: policyengine-household-analytics
|
|
3
|
+
Version: 0.29.6
|
|
4
|
+
Summary: Analytics contract and persistence for the PolicyEngine Household API
|
|
5
|
+
Author-email: PolicyEngine <hello@policyengine.org>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: alembic>=1.13.0
|
|
8
|
+
Requires-Dist: cloud-sql-python-connector
|
|
9
|
+
Requires-Dist: flask-sqlalchemy>=3
|
|
10
|
+
Requires-Dist: flask>=2.2
|
|
11
|
+
Requires-Dist: policyengine-household-common==0.29.6
|
|
12
|
+
Requires-Dist: pymysql
|
|
13
|
+
Requires-Dist: sqlalchemy>=2
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# policyengine-household-analytics
|
|
17
|
+
|
|
18
|
+
Analytics contract and persistence for the PolicyEngine Household API: the
|
|
19
|
+
calculate-analytics event schema, SQLAlchemy ORM models, analytics database
|
|
20
|
+
setup, and the Alembic migration scripts (shipped as package data).
|
|
21
|
+
|
|
22
|
+
The slim Cloud Run analytics writer installs this lib, so its dependency
|
|
23
|
+
closure deliberately excludes numpy, country model packages, and modal.
|
|
24
|
+
|
|
25
|
+
Published to PyPI because `policyengine-household-api` depends on it. It is
|
|
26
|
+
not a standalone product; its API follows the needs of the Household API
|
|
27
|
+
services.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# policyengine-household-analytics
|
|
2
|
+
|
|
3
|
+
Analytics contract and persistence for the PolicyEngine Household API: the
|
|
4
|
+
calculate-analytics event schema, SQLAlchemy ORM models, analytics database
|
|
5
|
+
setup, and the Alembic migration scripts (shipped as package data).
|
|
6
|
+
|
|
7
|
+
The slim Cloud Run analytics writer installs this lib, so its dependency
|
|
8
|
+
closure deliberately excludes numpy, country model packages, and modal.
|
|
9
|
+
|
|
10
|
+
Published to PyPI because `policyengine-household-api` depends on it. It is
|
|
11
|
+
not a standalone product; its API follows the needs of the Household API
|
|
12
|
+
services.
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Alembic environment for the household API analytics database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from logging.config import fileConfig
|
|
6
|
+
|
|
7
|
+
from alembic import context
|
|
8
|
+
from sqlalchemy import create_engine, pool, text
|
|
9
|
+
|
|
10
|
+
from policyengine_household_analytics.analytics_setup import ( # noqa: E402
|
|
11
|
+
get_analytics_database_uri,
|
|
12
|
+
getconn,
|
|
13
|
+
)
|
|
14
|
+
from policyengine_household_analytics.analytics_setup import db # noqa: E402
|
|
15
|
+
import policyengine_household_analytics.orm # noqa: F401,E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
config = context.config
|
|
19
|
+
|
|
20
|
+
if config.config_file_name is not None:
|
|
21
|
+
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
|
22
|
+
|
|
23
|
+
target_metadata = db.metadata
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _database_uri() -> str:
|
|
27
|
+
x_args = context.get_x_argument(as_dictionary=True)
|
|
28
|
+
return x_args.get("database_url") or get_analytics_database_uri()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_migrations_offline() -> None:
|
|
32
|
+
context.configure(
|
|
33
|
+
url=_database_uri(),
|
|
34
|
+
target_metadata=target_metadata,
|
|
35
|
+
literal_binds=True,
|
|
36
|
+
dialect_opts={"paramstyle": "named"},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
with context.begin_transaction():
|
|
40
|
+
context.run_migrations()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_migrations_with_lock(connection) -> None:
|
|
44
|
+
context.configure(
|
|
45
|
+
connection=connection,
|
|
46
|
+
target_metadata=target_metadata,
|
|
47
|
+
render_as_batch=connection.dialect.name == "sqlite",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if connection.dialect.name != "mysql":
|
|
51
|
+
with context.begin_transaction():
|
|
52
|
+
context.run_migrations()
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
lock_name = "policyengine_household_api_alembic"
|
|
56
|
+
lock_acquired = connection.execute(
|
|
57
|
+
text("SELECT GET_LOCK(:lock_name, 30)"),
|
|
58
|
+
{"lock_name": lock_name},
|
|
59
|
+
).scalar()
|
|
60
|
+
if lock_acquired != 1:
|
|
61
|
+
raise RuntimeError("Could not acquire analytics migration lock")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with context.begin_transaction():
|
|
65
|
+
context.run_migrations()
|
|
66
|
+
finally:
|
|
67
|
+
connection.execute(
|
|
68
|
+
text("SELECT RELEASE_LOCK(:lock_name)"),
|
|
69
|
+
{"lock_name": lock_name},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_migrations_online() -> None:
|
|
74
|
+
database_uri = _database_uri()
|
|
75
|
+
engine_kwargs = {"poolclass": pool.NullPool}
|
|
76
|
+
if database_uri == "mysql+pymysql://":
|
|
77
|
+
engine_kwargs["creator"] = lambda: getconn(
|
|
78
|
+
require_analytics_enabled=False
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
connectable = create_engine(database_uri, **engine_kwargs)
|
|
82
|
+
|
|
83
|
+
with connectable.connect() as connection:
|
|
84
|
+
_run_migrations_with_lock(connection)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if context.is_offline_mode():
|
|
88
|
+
run_migrations_offline()
|
|
89
|
+
else:
|
|
90
|
+
run_migrations_online()
|
policyengine_household_analytics-0.29.6/policyengine_household_analytics/alembic/script.py.mako
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
${imports if imports else ""}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
revision = ${repr(up_revision)}
|
|
14
|
+
down_revision = ${repr(down_revision)}
|
|
15
|
+
branch_labels = ${repr(branch_labels)}
|
|
16
|
+
depends_on = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade() -> None:
|
|
24
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Baseline existing visits analytics table.
|
|
2
|
+
|
|
3
|
+
Revision ID: 20260508_0001
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-05-08 00:00:00
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
revision = "20260508_0001"
|
|
13
|
+
down_revision = None
|
|
14
|
+
branch_labels = None
|
|
15
|
+
depends_on = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def upgrade() -> None:
|
|
19
|
+
op.create_table(
|
|
20
|
+
"visits",
|
|
21
|
+
sa.Column("id", sa.Integer(), primary_key=True),
|
|
22
|
+
sa.Column("client_id", sa.String(length=255), nullable=False),
|
|
23
|
+
sa.Column("datetime", sa.DateTime(), nullable=True),
|
|
24
|
+
sa.Column("api_version", sa.String(length=32), nullable=True),
|
|
25
|
+
sa.Column("endpoint", sa.String(length=64), nullable=True),
|
|
26
|
+
sa.Column("method", sa.String(length=32), nullable=True),
|
|
27
|
+
sa.Column("content_length_bytes", sa.Integer(), nullable=True),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def downgrade() -> None:
|
|
32
|
+
op.drop_table("visits")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""calculate variable usage
|
|
2
|
+
|
|
3
|
+
Revision ID: 20260508_0002
|
|
4
|
+
Revises: 20260508_0001
|
|
5
|
+
Create Date: 2026-05-08 20:05:06.418279
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
revision = "20260508_0002"
|
|
13
|
+
down_revision = "20260508_0001"
|
|
14
|
+
branch_labels = None
|
|
15
|
+
depends_on = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def upgrade() -> None:
|
|
19
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
20
|
+
op.create_table(
|
|
21
|
+
"calculate_requests",
|
|
22
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
23
|
+
sa.Column("visit_id", sa.Integer(), nullable=False),
|
|
24
|
+
sa.Column("request_uuid", sa.String(length=36), nullable=False),
|
|
25
|
+
sa.Column("client_id", sa.String(length=255), nullable=True),
|
|
26
|
+
sa.Column("api_version", sa.String(length=32), nullable=True),
|
|
27
|
+
sa.Column("country_id", sa.String(length=16), nullable=False),
|
|
28
|
+
sa.Column("model_version", sa.String(length=64), nullable=True),
|
|
29
|
+
sa.Column("endpoint", sa.String(length=64), nullable=True),
|
|
30
|
+
sa.Column("method", sa.String(length=16), nullable=False),
|
|
31
|
+
sa.Column("content_length_bytes", sa.Integer(), nullable=True),
|
|
32
|
+
sa.Column("response_status_code", sa.Integer(), nullable=True),
|
|
33
|
+
sa.Column("distinct_variable_count", sa.Integer(), nullable=False),
|
|
34
|
+
sa.Column("unsupported_variable_count", sa.Integer(), nullable=False),
|
|
35
|
+
sa.Column(
|
|
36
|
+
"deprecated_allowlisted_variable_count",
|
|
37
|
+
sa.Integer(),
|
|
38
|
+
nullable=False,
|
|
39
|
+
),
|
|
40
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
41
|
+
sa.ForeignKeyConstraint(
|
|
42
|
+
["visit_id"],
|
|
43
|
+
["visits.id"],
|
|
44
|
+
),
|
|
45
|
+
sa.PrimaryKeyConstraint("id"),
|
|
46
|
+
sa.UniqueConstraint("request_uuid"),
|
|
47
|
+
)
|
|
48
|
+
with op.batch_alter_table("calculate_requests", schema=None) as batch_op:
|
|
49
|
+
batch_op.create_index(
|
|
50
|
+
"ix_calculate_requests_client_created",
|
|
51
|
+
["client_id", "created_at"],
|
|
52
|
+
unique=False,
|
|
53
|
+
)
|
|
54
|
+
batch_op.create_index(
|
|
55
|
+
"ix_calculate_requests_country_created",
|
|
56
|
+
["country_id", "created_at"],
|
|
57
|
+
unique=False,
|
|
58
|
+
)
|
|
59
|
+
batch_op.create_index(
|
|
60
|
+
"ix_calculate_requests_visit_id", ["visit_id"], unique=False
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
op.create_table(
|
|
64
|
+
"calculate_request_variables",
|
|
65
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
66
|
+
sa.Column("request_id", sa.Integer(), nullable=False),
|
|
67
|
+
sa.Column("client_id", sa.String(length=255), nullable=True),
|
|
68
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
69
|
+
sa.Column("country_id", sa.String(length=16), nullable=False),
|
|
70
|
+
sa.Column("api_version", sa.String(length=32), nullable=True),
|
|
71
|
+
sa.Column("model_version", sa.String(length=64), nullable=True),
|
|
72
|
+
sa.Column("response_status_code", sa.Integer(), nullable=True),
|
|
73
|
+
sa.Column("variable_name", sa.String(length=255), nullable=False),
|
|
74
|
+
sa.Column("entity_type", sa.String(length=64), nullable=False),
|
|
75
|
+
sa.Column("source", sa.String(length=32), nullable=False),
|
|
76
|
+
sa.Column("period_granularity", sa.String(length=16), nullable=False),
|
|
77
|
+
sa.Column("entity_count", sa.Integer(), nullable=False),
|
|
78
|
+
sa.Column("period_count", sa.Integer(), nullable=False),
|
|
79
|
+
sa.Column("occurrence_count", sa.Integer(), nullable=False),
|
|
80
|
+
sa.Column("availability_status", sa.String(length=32), nullable=False),
|
|
81
|
+
sa.ForeignKeyConstraint(
|
|
82
|
+
["request_id"],
|
|
83
|
+
["calculate_requests.id"],
|
|
84
|
+
),
|
|
85
|
+
sa.PrimaryKeyConstraint("id"),
|
|
86
|
+
sa.UniqueConstraint(
|
|
87
|
+
"request_id",
|
|
88
|
+
"variable_name",
|
|
89
|
+
"entity_type",
|
|
90
|
+
"source",
|
|
91
|
+
name="ux_calc_vars_request_variable_entity_source",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
with op.batch_alter_table(
|
|
95
|
+
"calculate_request_variables", schema=None
|
|
96
|
+
) as batch_op:
|
|
97
|
+
batch_op.create_index(
|
|
98
|
+
"ix_calc_vars_client_variable_created",
|
|
99
|
+
["client_id", "variable_name", "created_at"],
|
|
100
|
+
unique=False,
|
|
101
|
+
)
|
|
102
|
+
batch_op.create_index(
|
|
103
|
+
"ix_calc_vars_country_model_variable",
|
|
104
|
+
["country_id", "model_version", "variable_name"],
|
|
105
|
+
unique=False,
|
|
106
|
+
)
|
|
107
|
+
batch_op.create_index(
|
|
108
|
+
"ix_calc_vars_variable_created",
|
|
109
|
+
["variable_name", "created_at"],
|
|
110
|
+
unique=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
with op.batch_alter_table("visits", schema=None) as batch_op:
|
|
114
|
+
batch_op.alter_column(
|
|
115
|
+
"client_id", existing_type=sa.VARCHAR(length=255), nullable=True
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ### end Alembic commands ###
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def downgrade() -> None:
|
|
122
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
123
|
+
op.execute("UPDATE visits SET client_id = '' WHERE client_id IS NULL")
|
|
124
|
+
with op.batch_alter_table("visits", schema=None) as batch_op:
|
|
125
|
+
batch_op.alter_column(
|
|
126
|
+
"client_id", existing_type=sa.VARCHAR(length=255), nullable=False
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
with op.batch_alter_table(
|
|
130
|
+
"calculate_request_variables", schema=None
|
|
131
|
+
) as batch_op:
|
|
132
|
+
batch_op.drop_index("ix_calc_vars_variable_created")
|
|
133
|
+
batch_op.drop_index("ix_calc_vars_country_model_variable")
|
|
134
|
+
batch_op.drop_index("ix_calc_vars_client_variable_created")
|
|
135
|
+
|
|
136
|
+
op.drop_table("calculate_request_variables")
|
|
137
|
+
with op.batch_alter_table("calculate_requests", schema=None) as batch_op:
|
|
138
|
+
batch_op.drop_index("ix_calculate_requests_visit_id")
|
|
139
|
+
batch_op.drop_index("ix_calculate_requests_country_created")
|
|
140
|
+
batch_op.drop_index("ix_calculate_requests_client_created")
|
|
141
|
+
|
|
142
|
+
op.drop_table("calculate_requests")
|
|
143
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""cap analytics variable names
|
|
2
|
+
|
|
3
|
+
Downgrade note:
|
|
4
|
+
This migration is not data-preserving for variable names longer than
|
|
5
|
+
250 characters. Those names are capped before storage in this revision,
|
|
6
|
+
so downgrading deletes rows marked as truncated before restoring the old
|
|
7
|
+
per-request uniqueness constraint.
|
|
8
|
+
|
|
9
|
+
Revision ID: 20260512_0003
|
|
10
|
+
Revises: 20260508_0002
|
|
11
|
+
Create Date: 2026-05-12 13:42:10.751334
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from alembic import op
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
revision = "20260512_0003"
|
|
19
|
+
down_revision = "20260508_0002"
|
|
20
|
+
branch_labels = None
|
|
21
|
+
depends_on = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
with op.batch_alter_table(
|
|
27
|
+
"calculate_request_variables", schema=None
|
|
28
|
+
) as batch_op:
|
|
29
|
+
batch_op.add_column(
|
|
30
|
+
sa.Column(
|
|
31
|
+
"variable_name_truncated",
|
|
32
|
+
sa.Boolean(),
|
|
33
|
+
server_default=sa.false(),
|
|
34
|
+
nullable=False,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
batch_op.create_index(
|
|
38
|
+
"ix_calc_vars_request_id",
|
|
39
|
+
["request_id"],
|
|
40
|
+
unique=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
with op.batch_alter_table(
|
|
44
|
+
"calculate_request_variables", schema=None
|
|
45
|
+
) as batch_op:
|
|
46
|
+
batch_op.drop_constraint(
|
|
47
|
+
batch_op.f("ux_calc_vars_request_variable_entity_source"),
|
|
48
|
+
type_="unique",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ### end Alembic commands ###
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def downgrade() -> None:
|
|
55
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
56
|
+
calculate_request_variables = sa.table(
|
|
57
|
+
"calculate_request_variables",
|
|
58
|
+
sa.column("variable_name_truncated", sa.Boolean()),
|
|
59
|
+
)
|
|
60
|
+
op.execute(
|
|
61
|
+
calculate_request_variables.delete().where(
|
|
62
|
+
calculate_request_variables.c.variable_name_truncated.is_(True)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
with op.batch_alter_table(
|
|
67
|
+
"calculate_request_variables", schema=None
|
|
68
|
+
) as batch_op:
|
|
69
|
+
batch_op.create_unique_constraint(
|
|
70
|
+
batch_op.f("ux_calc_vars_request_variable_entity_source"),
|
|
71
|
+
["request_id", "variable_name", "entity_type", "source"],
|
|
72
|
+
)
|
|
73
|
+
batch_op.drop_index("ix_calc_vars_request_id")
|
|
74
|
+
batch_op.drop_column("variable_name_truncated")
|
|
75
|
+
|
|
76
|
+
# ### end Alembic commands ###
|
|
@@ -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 ###
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analytics database setup with opt-in configuration support.
|
|
3
|
+
This module provides conditional analytics database connectivity based on configuration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from functools import cache
|
|
10
|
+
from policyengine_household_common.config_loader import get_config_value
|
|
11
|
+
from policyengine_household_common.analytics_migration import (
|
|
12
|
+
ANALYTICS_ALEMBIC_MINIMUM_REVISION,
|
|
13
|
+
)
|
|
14
|
+
from google.cloud.sql.connector import Connector
|
|
15
|
+
from google.cloud.sql.connector import IPTypes
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from sqlalchemy import inspect
|
|
18
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
19
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Global variable to store whether analytics is enabled
|
|
24
|
+
_analytics_enabled = None
|
|
25
|
+
_analytics_schema_ready = True
|
|
26
|
+
_connector = None
|
|
27
|
+
|
|
28
|
+
ANALYTICS_DATABASE_URL_ENV_VAR = "ANALYTICS_DATABASE_URL"
|
|
29
|
+
ANALYTICS_DATABASE_NAME = "user_analytics"
|
|
30
|
+
ALEMBIC_REVISION_ID_PATTERN = re.compile(r"^\d{8}_\d{4}$")
|
|
31
|
+
REQUIRED_ANALYTICS_COLUMNS = {
|
|
32
|
+
"visits": {
|
|
33
|
+
"id",
|
|
34
|
+
"client_id",
|
|
35
|
+
"datetime",
|
|
36
|
+
"api_version",
|
|
37
|
+
"endpoint",
|
|
38
|
+
"method",
|
|
39
|
+
"content_length_bytes",
|
|
40
|
+
},
|
|
41
|
+
"calculate_requests": {
|
|
42
|
+
"id",
|
|
43
|
+
"visit_id",
|
|
44
|
+
"request_uuid",
|
|
45
|
+
"client_id",
|
|
46
|
+
"api_version",
|
|
47
|
+
"country_id",
|
|
48
|
+
"model_version",
|
|
49
|
+
"requested_version",
|
|
50
|
+
"resolved_channel",
|
|
51
|
+
"endpoint",
|
|
52
|
+
"method",
|
|
53
|
+
"content_length_bytes",
|
|
54
|
+
"response_status_code",
|
|
55
|
+
"distinct_variable_count",
|
|
56
|
+
"unsupported_variable_count",
|
|
57
|
+
"deprecated_allowlisted_variable_count",
|
|
58
|
+
"created_at",
|
|
59
|
+
},
|
|
60
|
+
"calculate_request_variables": {
|
|
61
|
+
"id",
|
|
62
|
+
"request_id",
|
|
63
|
+
"client_id",
|
|
64
|
+
"created_at",
|
|
65
|
+
"country_id",
|
|
66
|
+
"api_version",
|
|
67
|
+
"model_version",
|
|
68
|
+
"requested_version",
|
|
69
|
+
"resolved_channel",
|
|
70
|
+
"response_status_code",
|
|
71
|
+
"variable_name",
|
|
72
|
+
"variable_name_truncated",
|
|
73
|
+
"entity_type",
|
|
74
|
+
"source",
|
|
75
|
+
"period_granularity",
|
|
76
|
+
"entity_count",
|
|
77
|
+
"period_count",
|
|
78
|
+
"occurrence_count",
|
|
79
|
+
"availability_status",
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Configure db schema, but don't initialize db itself
|
|
85
|
+
class Base(DeclarativeBase):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
db = SQLAlchemy(model_class=Base)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def configure_analytics_db_if_enabled(app):
|
|
93
|
+
"""
|
|
94
|
+
Register the analytics database extension with Flask, if enabled.
|
|
95
|
+
|
|
96
|
+
This does not connect to the database or validate schema readiness. Flask
|
|
97
|
+
extensions must be registered before the first request is handled, but the
|
|
98
|
+
API request path should not depend on analytics storage being reachable.
|
|
99
|
+
"""
|
|
100
|
+
if not is_analytics_enabled():
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if "sqlalchemy" in app.extensions:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
database_uri = get_analytics_database_uri()
|
|
107
|
+
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
|
|
108
|
+
if database_uri == "mysql+pymysql://":
|
|
109
|
+
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"creator": getconn}
|
|
110
|
+
|
|
111
|
+
db.init_app(app)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def initialize_analytics_db_if_enabled(app):
|
|
115
|
+
"""
|
|
116
|
+
Initialize database configuration for the Flask app, if enabled.
|
|
117
|
+
Return a bool corresponding with whether or not it's enabled,
|
|
118
|
+
as well as the SQLAlchemy instance if it is enabled or None if not.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
app: Flask application instance
|
|
122
|
+
"""
|
|
123
|
+
if not is_analytics_enabled():
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
configure_analytics_db_if_enabled(app)
|
|
127
|
+
|
|
128
|
+
with app.app_context():
|
|
129
|
+
schema_ready = check_analytics_schema_ready()
|
|
130
|
+
set_analytics_schema_ready(schema_ready)
|
|
131
|
+
if not schema_ready:
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
"Analytics is enabled but the analytics database schema is "
|
|
134
|
+
"not ready. Run `uv run alembic upgrade head` and verify "
|
|
135
|
+
"database connectivity before starting the API."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_local_analytics_database_path() -> Path:
|
|
140
|
+
# Debug-mode-only sqlite file. Resolved from the working directory so
|
|
141
|
+
# this lib never imports the core package (the writer image installs the
|
|
142
|
+
# analytics lib without the core package present).
|
|
143
|
+
return Path.cwd() / "local" / "policyengine_analytics.db"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_analytics_database_uri() -> str:
|
|
147
|
+
database_url = os.getenv(
|
|
148
|
+
ANALYTICS_DATABASE_URL_ENV_VAR
|
|
149
|
+
) or get_config_value("analytics.database.url", "")
|
|
150
|
+
if database_url:
|
|
151
|
+
return str(database_url)
|
|
152
|
+
|
|
153
|
+
if get_config_value("app.debug", False):
|
|
154
|
+
db_path = get_local_analytics_database_path()
|
|
155
|
+
should_reset = os.getenv("RESET_ANALYTICS", "").lower() in (
|
|
156
|
+
"1",
|
|
157
|
+
"true",
|
|
158
|
+
"yes",
|
|
159
|
+
) or get_config_value("analytics.reset", False)
|
|
160
|
+
if should_reset and db_path.exists():
|
|
161
|
+
db_path.unlink()
|
|
162
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
return f"sqlite:///{db_path}"
|
|
164
|
+
|
|
165
|
+
return "mysql+pymysql://"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def set_analytics_schema_ready(is_ready: bool) -> None:
|
|
169
|
+
global _analytics_schema_ready
|
|
170
|
+
_analytics_schema_ready = is_ready
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_analytics_schema_ready() -> bool:
|
|
174
|
+
return _analytics_schema_ready
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_analytics_schema_ready() -> bool:
|
|
178
|
+
try:
|
|
179
|
+
inspector = inspect(db.engine)
|
|
180
|
+
missing = list(_missing_required_schema(inspector))
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Could not inspect analytics database schema: {e}")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
if missing:
|
|
186
|
+
logger.error(
|
|
187
|
+
"Analytics database schema is not ready; run "
|
|
188
|
+
"`uv run alembic upgrade head`. Missing: " + ", ".join(missing)
|
|
189
|
+
)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
alembic_version = _alembic_version()
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"Could not inspect analytics migration version: {e}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
if not _alembic_revision_is_compatible(alembic_version):
|
|
199
|
+
logger.error(
|
|
200
|
+
"Analytics database schema is not at or after required Alembic "
|
|
201
|
+
f"revision {ANALYTICS_ALEMBIC_MINIMUM_REVISION}; current "
|
|
202
|
+
"revision is "
|
|
203
|
+
f"{alembic_version or 'missing'}. Run `uv run alembic upgrade head`."
|
|
204
|
+
)
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _missing_required_schema(inspector):
|
|
211
|
+
required_columns = {"visits": REQUIRED_ANALYTICS_COLUMNS["visits"]}
|
|
212
|
+
if _collect_variable_usage_enabled():
|
|
213
|
+
required_columns["calculate_requests"] = REQUIRED_ANALYTICS_COLUMNS[
|
|
214
|
+
"calculate_requests"
|
|
215
|
+
]
|
|
216
|
+
required_columns["calculate_request_variables"] = (
|
|
217
|
+
REQUIRED_ANALYTICS_COLUMNS["calculate_request_variables"]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
for table_name, column_names in required_columns.items():
|
|
221
|
+
if not inspector.has_table(table_name):
|
|
222
|
+
yield table_name
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
existing_columns = {
|
|
226
|
+
column["name"] for column in inspector.get_columns(table_name)
|
|
227
|
+
}
|
|
228
|
+
for missing_column in sorted(column_names - existing_columns):
|
|
229
|
+
yield f"{table_name}.{missing_column}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _collect_variable_usage_enabled() -> bool:
|
|
233
|
+
value = get_config_value("analytics.collect_variable_usage", True)
|
|
234
|
+
if isinstance(value, str):
|
|
235
|
+
return value.lower() not in {"0", "false", "no"}
|
|
236
|
+
return bool(value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _alembic_version() -> str | None:
|
|
240
|
+
inspector = inspect(db.engine)
|
|
241
|
+
if not inspector.has_table("alembic_version"):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
with db.engine.connect() as connection:
|
|
245
|
+
return connection.exec_driver_sql(
|
|
246
|
+
"SELECT version_num FROM alembic_version"
|
|
247
|
+
).scalar()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _alembic_revision_is_compatible(
|
|
251
|
+
current_revision: str | None,
|
|
252
|
+
minimum_revision: str = ANALYTICS_ALEMBIC_MINIMUM_REVISION,
|
|
253
|
+
) -> bool:
|
|
254
|
+
if not current_revision:
|
|
255
|
+
return False
|
|
256
|
+
if current_revision == minimum_revision:
|
|
257
|
+
return True
|
|
258
|
+
if _known_revision_descends_from(current_revision, minimum_revision):
|
|
259
|
+
return True
|
|
260
|
+
return _revision_id_is_later_than(current_revision, minimum_revision)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _known_revision_descends_from(
|
|
264
|
+
current_revision: str,
|
|
265
|
+
minimum_revision: str,
|
|
266
|
+
) -> bool:
|
|
267
|
+
try:
|
|
268
|
+
script = _alembic_script_directory()
|
|
269
|
+
current = script.get_revision(current_revision)
|
|
270
|
+
minimum = script.get_revision(minimum_revision)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.debug(f"Could not resolve Alembic revision locally: {e}")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
if current is None or minimum is None:
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
pending = [current]
|
|
279
|
+
seen = set()
|
|
280
|
+
while pending:
|
|
281
|
+
revision = pending.pop()
|
|
282
|
+
if revision.revision == minimum.revision:
|
|
283
|
+
return True
|
|
284
|
+
if revision.revision in seen:
|
|
285
|
+
continue
|
|
286
|
+
seen.add(revision.revision)
|
|
287
|
+
for down_revision in _down_revisions(revision.down_revision):
|
|
288
|
+
try:
|
|
289
|
+
parent = script.get_revision(down_revision)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.debug(
|
|
292
|
+
"Could not resolve Alembic parent revision "
|
|
293
|
+
f"{down_revision}: {e}"
|
|
294
|
+
)
|
|
295
|
+
return False
|
|
296
|
+
if parent is not None:
|
|
297
|
+
pending.append(parent)
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@cache
|
|
302
|
+
def _alembic_script_directory():
|
|
303
|
+
from alembic.config import Config
|
|
304
|
+
from alembic.script import ScriptDirectory
|
|
305
|
+
|
|
306
|
+
# Migration scripts ship as package data, so revision ancestry checks
|
|
307
|
+
# work in every runtime that installs this lib — previously they resolved
|
|
308
|
+
# from the repo root and silently fell back to string comparison in
|
|
309
|
+
# images that shipped no alembic/ directory.
|
|
310
|
+
alembic_config = Config()
|
|
311
|
+
alembic_config.set_main_option(
|
|
312
|
+
"script_location",
|
|
313
|
+
str(Path(__file__).resolve().parent / "alembic"),
|
|
314
|
+
)
|
|
315
|
+
return ScriptDirectory.from_config(alembic_config)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _down_revisions(down_revision):
|
|
319
|
+
if down_revision is None:
|
|
320
|
+
return ()
|
|
321
|
+
if isinstance(down_revision, str):
|
|
322
|
+
return (down_revision,)
|
|
323
|
+
return tuple(down_revision)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _revision_id_is_later_than(
|
|
327
|
+
current_revision: str,
|
|
328
|
+
minimum_revision: str,
|
|
329
|
+
) -> bool:
|
|
330
|
+
return (
|
|
331
|
+
bool(ALEMBIC_REVISION_ID_PATTERN.fullmatch(current_revision))
|
|
332
|
+
and bool(ALEMBIC_REVISION_ID_PATTERN.fullmatch(minimum_revision))
|
|
333
|
+
and current_revision > minimum_revision
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def is_analytics_enabled() -> bool:
|
|
338
|
+
"""
|
|
339
|
+
Check if analytics is enabled based on configuration.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
bool: True if analytics is enabled, False otherwise
|
|
343
|
+
"""
|
|
344
|
+
global _analytics_enabled
|
|
345
|
+
|
|
346
|
+
if _analytics_enabled is not None:
|
|
347
|
+
return _analytics_enabled
|
|
348
|
+
|
|
349
|
+
# Try to get from config first (future use when app migrates to ConfigLoader)
|
|
350
|
+
_analytics_enabled = get_config_value("analytics.enabled", False)
|
|
351
|
+
return _analytics_enabled
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_analytics_connector(require_analytics_enabled: bool = True):
|
|
355
|
+
"""
|
|
356
|
+
Get the Google Cloud SQL connector for analytics.
|
|
357
|
+
Returns None if analytics is disabled.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Connector or None: The connector instance if analytics is enabled, None otherwise
|
|
361
|
+
"""
|
|
362
|
+
global _connector
|
|
363
|
+
|
|
364
|
+
if require_analytics_enabled and not is_analytics_enabled():
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
if _connector is None:
|
|
368
|
+
try:
|
|
369
|
+
_connector = Connector()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.error(f"Failed to initialize analytics connector: {e}")
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
return _connector
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def getconn(require_analytics_enabled: bool = True):
|
|
378
|
+
"""
|
|
379
|
+
Get a connection to the analytics database.
|
|
380
|
+
Returns None if analytics is disabled.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Connection or None: Database connection if analytics is enabled, None otherwise
|
|
384
|
+
"""
|
|
385
|
+
if require_analytics_enabled and not is_analytics_enabled():
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
connector = get_analytics_connector(require_analytics_enabled)
|
|
389
|
+
if not connector:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
connection_name = get_config_value(
|
|
394
|
+
"analytics.database.connection_name",
|
|
395
|
+
)
|
|
396
|
+
username = get_config_value(
|
|
397
|
+
"analytics.database.username",
|
|
398
|
+
)
|
|
399
|
+
password = get_config_value(
|
|
400
|
+
"analytics.database.password",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if not connection_name or not username or not password:
|
|
404
|
+
logger.error(
|
|
405
|
+
"Analytics enabled but problem with one or more of the following configuration values: connection_name, username, password"
|
|
406
|
+
)
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
conn = connector.connect(
|
|
410
|
+
connection_name,
|
|
411
|
+
"pymysql",
|
|
412
|
+
user=username,
|
|
413
|
+
password=password,
|
|
414
|
+
db=ANALYTICS_DATABASE_NAME,
|
|
415
|
+
ip_type=IPTypes.PUBLIC,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return conn
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.error(f"Failed to connect to analytics database: {e}")
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def cleanup():
|
|
426
|
+
"""Clean up the analytics connector if it exists."""
|
|
427
|
+
global _connector
|
|
428
|
+
if _connector:
|
|
429
|
+
try:
|
|
430
|
+
_connector.close()
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
_connector = None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from policyengine_household_common.models.analytics import AnalyticsContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CalculateAnalyticsEvent(BaseModel):
|
|
11
|
+
"""Value-free analytics event persisted outside the request path."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(frozen=True)
|
|
14
|
+
|
|
15
|
+
schema_version: Literal[1] = 1
|
|
16
|
+
context: AnalyticsContext
|
|
17
|
+
response_status_code: int | None = None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from policyengine_household_analytics.analytics_setup import db
|
|
2
|
+
from policyengine_household_common.models.analytics import (
|
|
3
|
+
AnalyticsHttpMethod,
|
|
4
|
+
AvailabilityStatus,
|
|
5
|
+
ModalResolvedChannel,
|
|
6
|
+
PeriodGranularity,
|
|
7
|
+
VariableSource,
|
|
8
|
+
)
|
|
9
|
+
from sqlalchemy import (
|
|
10
|
+
Boolean,
|
|
11
|
+
DateTime,
|
|
12
|
+
ForeignKey,
|
|
13
|
+
Index,
|
|
14
|
+
Integer,
|
|
15
|
+
String,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.orm import mapped_column
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _enum_values(enum_type) -> tuple[str, ...]:
|
|
21
|
+
return tuple(member.value for member in enum_type)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Visit(db.Model):
|
|
25
|
+
# Note that the model represents one visit,
|
|
26
|
+
# while the table name is plural
|
|
27
|
+
__tablename__ = "visits"
|
|
28
|
+
id = mapped_column(Integer, primary_key=True)
|
|
29
|
+
client_id = mapped_column(String(255), nullable=True)
|
|
30
|
+
datetime = mapped_column(DateTime)
|
|
31
|
+
api_version = mapped_column(String(32))
|
|
32
|
+
endpoint = mapped_column(String(64))
|
|
33
|
+
method = mapped_column(
|
|
34
|
+
String(32),
|
|
35
|
+
info={"options": _enum_values(AnalyticsHttpMethod)},
|
|
36
|
+
)
|
|
37
|
+
content_length_bytes = mapped_column(Integer)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CalculateRequest(db.Model):
|
|
41
|
+
"""One analytics record for an inbound /calculate request."""
|
|
42
|
+
|
|
43
|
+
__tablename__ = "calculate_requests"
|
|
44
|
+
|
|
45
|
+
id = mapped_column(Integer, primary_key=True)
|
|
46
|
+
visit_id = mapped_column(
|
|
47
|
+
Integer,
|
|
48
|
+
ForeignKey("visits.id"),
|
|
49
|
+
nullable=False,
|
|
50
|
+
)
|
|
51
|
+
request_uuid = mapped_column(String(36), nullable=False, unique=True)
|
|
52
|
+
client_id = mapped_column(String(255), nullable=True)
|
|
53
|
+
api_version = mapped_column(String(32), nullable=True)
|
|
54
|
+
country_id = mapped_column(String(16), nullable=False)
|
|
55
|
+
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
|
+
)
|
|
62
|
+
endpoint = mapped_column(String(64), nullable=True)
|
|
63
|
+
method = mapped_column(
|
|
64
|
+
String(16),
|
|
65
|
+
nullable=False,
|
|
66
|
+
info={"options": _enum_values(AnalyticsHttpMethod)},
|
|
67
|
+
)
|
|
68
|
+
content_length_bytes = mapped_column(Integer, nullable=True)
|
|
69
|
+
response_status_code = mapped_column(Integer, nullable=True)
|
|
70
|
+
distinct_variable_count = mapped_column(Integer, nullable=False, default=0)
|
|
71
|
+
unsupported_variable_count = mapped_column(
|
|
72
|
+
Integer, nullable=False, default=0
|
|
73
|
+
)
|
|
74
|
+
deprecated_allowlisted_variable_count = mapped_column(
|
|
75
|
+
Integer, nullable=False, default=0
|
|
76
|
+
)
|
|
77
|
+
created_at = mapped_column(DateTime, nullable=False)
|
|
78
|
+
|
|
79
|
+
__table_args__ = (
|
|
80
|
+
Index(
|
|
81
|
+
"ix_calculate_requests_client_created", "client_id", "created_at"
|
|
82
|
+
),
|
|
83
|
+
Index("ix_calculate_requests_visit_id", "visit_id"),
|
|
84
|
+
Index(
|
|
85
|
+
"ix_calculate_requests_country_created",
|
|
86
|
+
"country_id",
|
|
87
|
+
"created_at",
|
|
88
|
+
),
|
|
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
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CalculateRequestVariable(db.Model):
|
|
103
|
+
"""A grouped variable-usage row derived from one calculate request."""
|
|
104
|
+
|
|
105
|
+
__tablename__ = "calculate_request_variables"
|
|
106
|
+
|
|
107
|
+
id = mapped_column(Integer, primary_key=True)
|
|
108
|
+
request_id = mapped_column(
|
|
109
|
+
Integer,
|
|
110
|
+
ForeignKey("calculate_requests.id"),
|
|
111
|
+
nullable=False,
|
|
112
|
+
)
|
|
113
|
+
client_id = mapped_column(String(255), nullable=True)
|
|
114
|
+
created_at = mapped_column(DateTime, nullable=False)
|
|
115
|
+
country_id = mapped_column(String(16), nullable=False)
|
|
116
|
+
api_version = mapped_column(String(32), nullable=True)
|
|
117
|
+
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
|
+
)
|
|
124
|
+
response_status_code = mapped_column(Integer, nullable=True)
|
|
125
|
+
variable_name = mapped_column(String(255), nullable=False)
|
|
126
|
+
variable_name_truncated = mapped_column(
|
|
127
|
+
Boolean,
|
|
128
|
+
nullable=False,
|
|
129
|
+
default=False,
|
|
130
|
+
)
|
|
131
|
+
entity_type = mapped_column(String(64), nullable=False)
|
|
132
|
+
source = mapped_column(
|
|
133
|
+
String(32),
|
|
134
|
+
nullable=False,
|
|
135
|
+
info={"options": _enum_values(VariableSource)},
|
|
136
|
+
)
|
|
137
|
+
period_granularity = mapped_column(
|
|
138
|
+
String(16),
|
|
139
|
+
nullable=False,
|
|
140
|
+
info={"options": _enum_values(PeriodGranularity)},
|
|
141
|
+
)
|
|
142
|
+
entity_count = mapped_column(Integer, nullable=False, default=0)
|
|
143
|
+
period_count = mapped_column(Integer, nullable=False, default=0)
|
|
144
|
+
occurrence_count = mapped_column(Integer, nullable=False, default=0)
|
|
145
|
+
availability_status = mapped_column(
|
|
146
|
+
String(32),
|
|
147
|
+
nullable=False,
|
|
148
|
+
info={"options": _enum_values(AvailabilityStatus)},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Do not make request_id + variable_name unique: overlong variable names
|
|
152
|
+
# are intentionally capped before persistence, so different originals can
|
|
153
|
+
# share one stored representation.
|
|
154
|
+
__table_args__ = (
|
|
155
|
+
Index("ix_calc_vars_request_id", "request_id"),
|
|
156
|
+
Index(
|
|
157
|
+
"ix_calc_vars_variable_created",
|
|
158
|
+
"variable_name",
|
|
159
|
+
"created_at",
|
|
160
|
+
),
|
|
161
|
+
Index(
|
|
162
|
+
"ix_calc_vars_client_variable_created",
|
|
163
|
+
"client_id",
|
|
164
|
+
"variable_name",
|
|
165
|
+
"created_at",
|
|
166
|
+
),
|
|
167
|
+
Index(
|
|
168
|
+
"ix_calc_vars_country_model_variable",
|
|
169
|
+
"country_id",
|
|
170
|
+
"model_version",
|
|
171
|
+
"variable_name",
|
|
172
|
+
),
|
|
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
|
+
),
|
|
183
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.exc import IntegrityError
|
|
6
|
+
|
|
7
|
+
from policyengine_household_analytics.events import CalculateAnalyticsEvent
|
|
8
|
+
from policyengine_household_analytics.analytics_setup import db
|
|
9
|
+
from policyengine_household_analytics.orm import (
|
|
10
|
+
CalculateRequest,
|
|
11
|
+
CalculateRequestVariable,
|
|
12
|
+
Visit,
|
|
13
|
+
)
|
|
14
|
+
from policyengine_household_common.models.analytics import (
|
|
15
|
+
AnalyticsContext,
|
|
16
|
+
VariableUsageSummary,
|
|
17
|
+
)
|
|
18
|
+
from policyengine_household_common.variable_usage_analytics import (
|
|
19
|
+
stored_variable_name,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record_calculate_analytics_event(
|
|
26
|
+
event: CalculateAnalyticsEvent,
|
|
27
|
+
) -> None:
|
|
28
|
+
record_analytics(event.context, event.response_status_code)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def record_analytics(
|
|
32
|
+
context: AnalyticsContext | None,
|
|
33
|
+
response_status_code: int | None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if context is None:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if _calculate_request_exists(context):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
visit = _build_visit(context)
|
|
43
|
+
db.session.add(visit)
|
|
44
|
+
db.session.flush()
|
|
45
|
+
visit_id = getattr(visit, "id", None)
|
|
46
|
+
|
|
47
|
+
variable_summaries = context.variable_summaries
|
|
48
|
+
calculate_request = _build_calculate_request(
|
|
49
|
+
context,
|
|
50
|
+
response_status_code,
|
|
51
|
+
variable_summaries,
|
|
52
|
+
visit_id,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if calculate_request is not None:
|
|
56
|
+
db.session.add(calculate_request)
|
|
57
|
+
db.session.flush()
|
|
58
|
+
for summary in variable_summaries:
|
|
59
|
+
db.session.add(
|
|
60
|
+
_build_calculate_request_variable(
|
|
61
|
+
calculate_request,
|
|
62
|
+
summary,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
db.session.commit()
|
|
67
|
+
except IntegrityError:
|
|
68
|
+
db.session.rollback()
|
|
69
|
+
if _calculate_request_exists(context):
|
|
70
|
+
return
|
|
71
|
+
logger.exception("Failed to log analytics due to integrity error")
|
|
72
|
+
raise
|
|
73
|
+
except Exception as e:
|
|
74
|
+
db.session.rollback()
|
|
75
|
+
logger.error(f"Failed to log analytics: {e}")
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _calculate_request_exists(context: AnalyticsContext) -> bool:
|
|
80
|
+
if not context.record_calculate_request:
|
|
81
|
+
return False
|
|
82
|
+
return (
|
|
83
|
+
db.session.query(CalculateRequest)
|
|
84
|
+
.filter_by(request_uuid=context.request_uuid)
|
|
85
|
+
.first()
|
|
86
|
+
is not None
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_visit(context: AnalyticsContext) -> Visit:
|
|
91
|
+
visit = Visit()
|
|
92
|
+
visit.client_id = context.client_id
|
|
93
|
+
visit.api_version = context.api_version
|
|
94
|
+
visit.endpoint = context.endpoint
|
|
95
|
+
visit.method = context.method.value
|
|
96
|
+
visit.content_length_bytes = context.content_length_bytes
|
|
97
|
+
visit.datetime = context.created_at
|
|
98
|
+
return visit
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_calculate_request(
|
|
102
|
+
context: AnalyticsContext,
|
|
103
|
+
response_status_code: int | None,
|
|
104
|
+
variable_summaries: tuple[VariableUsageSummary, ...],
|
|
105
|
+
visit_id: int | None,
|
|
106
|
+
) -> CalculateRequest | None:
|
|
107
|
+
if not context.record_calculate_request:
|
|
108
|
+
return None
|
|
109
|
+
if visit_id is None:
|
|
110
|
+
raise ValueError("Visit ID is required for calculate analytics")
|
|
111
|
+
|
|
112
|
+
distinct_variable_names = {
|
|
113
|
+
summary.variable_name for summary in variable_summaries
|
|
114
|
+
}
|
|
115
|
+
unsupported_variable_names = {
|
|
116
|
+
summary.variable_name
|
|
117
|
+
for summary in variable_summaries
|
|
118
|
+
if summary.availability_status == "unsupported"
|
|
119
|
+
}
|
|
120
|
+
deprecated_allowlisted_variable_names = {
|
|
121
|
+
summary.variable_name
|
|
122
|
+
for summary in variable_summaries
|
|
123
|
+
if summary.availability_status == "deprecated_allowlisted"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
calculate_request = CalculateRequest()
|
|
127
|
+
calculate_request.visit_id = visit_id
|
|
128
|
+
calculate_request.request_uuid = context.request_uuid
|
|
129
|
+
calculate_request.client_id = context.client_id
|
|
130
|
+
calculate_request.api_version = context.api_version
|
|
131
|
+
calculate_request.country_id = context.country_id
|
|
132
|
+
calculate_request.model_version = context.model_version
|
|
133
|
+
calculate_request.requested_version = context.requested_version
|
|
134
|
+
calculate_request.resolved_channel = (
|
|
135
|
+
context.resolved_channel.value if context.resolved_channel else None
|
|
136
|
+
)
|
|
137
|
+
calculate_request.endpoint = context.endpoint
|
|
138
|
+
calculate_request.method = context.method.value
|
|
139
|
+
calculate_request.content_length_bytes = context.content_length_bytes
|
|
140
|
+
calculate_request.response_status_code = response_status_code
|
|
141
|
+
calculate_request.distinct_variable_count = len(distinct_variable_names)
|
|
142
|
+
calculate_request.unsupported_variable_count = len(
|
|
143
|
+
unsupported_variable_names
|
|
144
|
+
)
|
|
145
|
+
calculate_request.deprecated_allowlisted_variable_count = len(
|
|
146
|
+
deprecated_allowlisted_variable_names
|
|
147
|
+
)
|
|
148
|
+
calculate_request.created_at = context.created_at
|
|
149
|
+
return calculate_request
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_calculate_request_variable(
|
|
153
|
+
calculate_request: CalculateRequest,
|
|
154
|
+
summary: VariableUsageSummary,
|
|
155
|
+
) -> CalculateRequestVariable:
|
|
156
|
+
variable = CalculateRequestVariable()
|
|
157
|
+
variable.request_id = calculate_request.id
|
|
158
|
+
variable.client_id = calculate_request.client_id
|
|
159
|
+
variable.created_at = calculate_request.created_at
|
|
160
|
+
variable.country_id = calculate_request.country_id
|
|
161
|
+
variable.api_version = calculate_request.api_version
|
|
162
|
+
variable.model_version = calculate_request.model_version
|
|
163
|
+
variable.requested_version = calculate_request.requested_version
|
|
164
|
+
variable.resolved_channel = calculate_request.resolved_channel
|
|
165
|
+
variable.response_status_code = calculate_request.response_status_code
|
|
166
|
+
(
|
|
167
|
+
variable.variable_name,
|
|
168
|
+
variable.variable_name_truncated,
|
|
169
|
+
) = stored_variable_name(summary.variable_name)
|
|
170
|
+
variable.entity_type = summary.entity_type
|
|
171
|
+
variable.source = summary.source.value
|
|
172
|
+
variable.period_granularity = summary.period_granularity.value
|
|
173
|
+
variable.entity_count = summary.entity_count
|
|
174
|
+
variable.period_count = summary.period_count
|
|
175
|
+
variable.occurrence_count = summary.occurrence_count
|
|
176
|
+
variable.availability_status = summary.availability_status.value
|
|
177
|
+
return variable
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "policyengine-household-analytics"
|
|
3
|
+
version = "0.29.6"
|
|
4
|
+
description = "Analytics contract and persistence for the PolicyEngine Household API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "PolicyEngine", email = "hello@policyengine.org" },
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
# The slim analytics writer image installs this lib: keep numpy, country
|
|
11
|
+
# model packages, and modal out of this closure.
|
|
12
|
+
dependencies = [
|
|
13
|
+
"policyengine-household-common==0.29.6",
|
|
14
|
+
"alembic>=1.13.0",
|
|
15
|
+
"cloud-sql-python-connector",
|
|
16
|
+
"flask>=2.2",
|
|
17
|
+
"flask-sqlalchemy>=3",
|
|
18
|
+
"pymysql",
|
|
19
|
+
"sqlalchemy>=2",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
policyengine-household-common = { workspace = true }
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
# Includes the alembic/ migration scripts as package data, so revision
|
|
31
|
+
# ancestry checks work in every runtime that installs this lib.
|
|
32
|
+
packages = ["policyengine_household_analytics"]
|