policyengine-household-analytics 0.29.3__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.
@@ -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.3
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.3
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.
@@ -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()
@@ -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.3"
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.3",
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"]