diff --git a/README.md b/README.md index e04e3df..8512b08 100644 --- a/README.md +++ b/README.md @@ -209,18 +209,22 @@ The legacy `GET /airflow-fleet-health` Better Stack monitor endpoint has been re version signal per church/app/platform. It uses the analytics metadata sent by the mobile and TV apps, including the exported `apollos_version`, `app_version`, `app_update_id`, `bundle_id`, `application_name`, `church`, `apollos_platform`, `source_revision`, and `source_version` fields. -Public App Store versions are also looked up by bundle ID when available so iOS rows can distinguish -the observed installed app version from the current production App Store version. Roku Segment -exports currently do not expose `apollos_version`, so Roku rows use the exported +The public US Apple lookup is shown separately for iOS bundle IDs, with the fetch time. It can +lag App Store Connect and must not be treated as an authoritative published release. The seen +build is the version reported by the selected installation in Segment, not the latest shipped +build or a measure of all installations. Roku Segment exports currently do not expose +`apollos_version`, so Roku rows use the exported `context_library_version` and are labelled as analytics library versions. The page first inspects `INFORMATION_SCHEMA.COLUMNS` for the configured Segment tables and only queries tables that expose a supported version signal, so Segment lifecycle-only app-store -`version` fields are not mistaken for Apollos runtime versions. Runtime rows are marked outdated -when their highest observed runtime is behind the highest runtime observed for the same platform in -the lookback window, or when the observed app version is behind an available App Store version. If -older clients are still active after a release, the dashboard keeps the highest observed runtime for -the app instead of letting the most recent older-client event hide it. Mobile rows prefer the +`version` fields are not mistaken for Apollos runtime versions. Source freshness means *behind +top seen* or *top seen* within the same platform; Roku uses *behind source*, *at source*, or +*ahead of source* against the target commit. None of these labels compare against a store release. +Missing or uncomparable signals (including malformed mobile runtimes) are *unverified* and show +no comparison. Mobile rows keep +the highest comparable runtime instead of letting a recent older-client event hide it; TV selects +the highest stable release tag and Roku selects its source version. Mobile rows prefer the `apollos` Segment dataset, TV rows prefer `apollos_tv`, and Roku rows prefer `apollos_roku` so the same app event is not counted twice when Segment exports overlap. TV rows show `TBD` until source metadata appears in Segment exports. Once those fields are present, diff --git a/app_versions.py b/app_versions.py index b895f84..a1dd358 100644 --- a/app_versions.py +++ b/app_versions.py @@ -48,8 +48,6 @@ "source_revision": ("source_revision", "sourceRevision"), "source_version": ("source_version", "sourceVersion"), "deployment_track": ("deployment_track", "deploymentTrack"), - "user_id": ("user_id", "userId"), - "anonymous_id": ("anonymous_id", "anonymousId"), } ROKU_ANALYTICS_VERSION_CANDIDATES = ("context_library_version",) @@ -57,6 +55,7 @@ STABLE_RELEASE_TAG_PATTERN = re.compile(r"^v\d{4}\.\d{2}\.\d{2}\.\d{2}$") ALPHA_RELEASE_TAG_PATTERN = re.compile(r"^(v\d{4}\.\d{2}\.\d{2}\.\d{2})-alpha\.\d+$") SHA_PATTERN = re.compile(r"^[0-9a-fA-F]{7,40}$") +RUNTIME_VERSION_PATTERN = re.compile(r"\d+(?:\.\d+)*") INTERNAL_DEPLOYMENT_TRACKS = {"beta", "development", "internal", "preview", "prerelease"} @@ -303,9 +302,7 @@ def _build_app_versions_query( deployment_track, source_dataset, source_table, - version_source, - user_id, - anonymous_id + version_source FROM version_events WHERE apollos_version IS NOT NULL AND apollos_version != '' ), @@ -361,8 +358,7 @@ def _build_app_versions_query( events.source_dataset, events.source_table, events.version_source, - MAX(events.seen_at) AS latest_seen_at, - COUNT(*) AS version_event_count + MAX(events.seen_at) AS latest_seen_at FROM app_identity_events events JOIN display_churches USING (app_identity_key) @@ -381,14 +377,6 @@ def _build_app_versions_query( events.source_dataset, events.source_table, events.version_source - ), - app_totals AS ( - SELECT - app_identity_key, - COUNT(*) AS event_count, - COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS user_count - FROM app_identity_events - GROUP BY app_identity_key ) SELECT observation.church, @@ -404,12 +392,8 @@ def _build_app_versions_query( observation.source_dataset, observation.source_table, observation.version_source, - observation.latest_seen_at, - totals.event_count, - totals.user_count + observation.latest_seen_at FROM version_observations observation - JOIN app_totals totals - USING (app_identity_key) ORDER BY observation.latest_seen_at DESC """ query_config = _query_job_config( @@ -512,6 +496,12 @@ def _annotate_version_status( else "apollos_version" ) ) + if ( + platform in {"ios", "android"} + and version + and not RUNTIME_VERSION_PATTERN.fullmatch(version) + ): + continue if version and ( platform not in latest_by_platform or compare_versions(version, latest_by_platform[platform]) > 0 @@ -523,10 +513,15 @@ def _annotate_version_status( updated = dict(row) platform = (_string_value(row.get("apollos_platform")) or "unknown").lower() version = _string_value(row.get("apollos_version")) + comparable_runtime = bool(version and RUNTIME_VERSION_PATTERN.fullmatch(version)) source_version = _string_value(row.get("source_version")) source_revision = _string_value(row.get("source_revision")) release_version = _string_value(row.get("canonical_source_version")) - latest_version = latest_by_platform.get(platform) + latest_version = ( + latest_by_platform.get(platform) + if platform in {"ios", "android", *RELEASE_TAG_PLATFORMS} + else None + ) is_outdated = False freshness_display = version or "TBD" revision_status = None @@ -538,23 +533,40 @@ def _annotate_version_status( freshness_display = source_revision[:7] if source_revision else "TBD" revision_status = roku_statuses.get(source_revision or "") is_outdated = revision_status == "behind" - elif version and latest_version: + elif platform in {"ios", "android"} and comparable_runtime and latest_version and version: is_outdated = compare_versions(version, latest_version) < 0 updated["is_outdated"] = is_outdated updated["freshness_display"] = freshness_display - if freshness_display == "TBD": - version_status_label = "TBD" - version_status_class = "observed" - elif platform == "roku" and revision_status not in {"ahead", "behind", "identical"}: - version_status_label = "Unverified" if revision_status else "Observed" - version_status_class = "observed" - elif platform in RELEASE_TAG_PLATFORMS and not release_version: + updated["comparison_display"] = ( + (_string_value(source_context.get("roku_target_revision")) or "")[:7] or "unknown" + if platform == "roku" + else latest_version or "unknown" + ) + if platform == "roku": + version_status_label = { + "behind": "Behind source", + "identical": "At source", + "ahead": "Ahead of source", + }.get(revision_status or "", "Unverified") + elif ( + platform not in {"ios", "android", *RELEASE_TAG_PLATFORMS} + or (platform in RELEASE_TAG_PLATFORMS and not release_version) + or (platform in {"ios", "android"} and not comparable_runtime) + or freshness_display == "TBD" + ): version_status_label = "Unverified" - version_status_class = "observed" + elif latest_version: + version_status_label = "Behind top seen" if is_outdated else "Top seen" else: - version_status_label = "Outdated" if is_outdated else "Current" - version_status_class = "outdated" if is_outdated else "current" + version_status_label = "Unverified" + version_status_class = ( + "observed" + if version_status_label == "Unverified" + else "outdated" + if is_outdated + else "current" + ) updated["version_status_label"] = version_status_label updated["version_status_class"] = version_status_class updated["latest_seen_display"] = format_timestamp(row.get("latest_seen_at")) @@ -588,15 +600,15 @@ def _enrich_app_store_versions(rows: list[dict[str, Any]]) -> list[dict[str, Any except ValueError as exc: logging.warning("App Store version lookup returned invalid JSON: %s", exc) + checked_at = format_timestamp(datetime.now().astimezone()) enriched = [] for row in rows: updated = dict(row) bundle_id = _string_value(row.get("bundle_id")) - store_version = ( - app_store_versions.get(bundle_id or "") - if _should_lookup_app_store_version(row) - else None - ) + should_lookup = _should_lookup_app_store_version(row) + if should_lookup: + updated["store_checked_display"] = checked_at + store_version = app_store_versions.get(bundle_id or "") if should_lookup else None if store_version and (version := _string_value(store_version.get("version"))): updated["latest_app_version"] = version updated["latest_app_version_source"] = "app_store" @@ -715,6 +727,16 @@ def _is_newer_observed_version( ) -> bool: candidate_version = _string_value(candidate.get("apollos_version")) current_version = _string_value(current.get("apollos_version")) + platform = (_string_value(candidate.get("apollos_platform")) or "unknown").lower() + if platform in {"ios", "android"}: + candidate_valid = bool( + candidate_version and RUNTIME_VERSION_PATTERN.fullmatch(candidate_version) + ) + current_valid = bool(current_version and RUNTIME_VERSION_PATTERN.fullmatch(current_version)) + if candidate_valid != current_valid: + return candidate_valid + if not candidate_valid: + candidate_version = current_version = None if candidate_version and current_version: version_compare = compare_versions(candidate_version, current_version) if version_compare != 0: diff --git a/templates/app_versions.html b/templates/app_versions.html index 71bf487..88cfc01 100644 --- a/templates/app_versions.html +++ b/templates/app_versions.html @@ -35,8 +35,7 @@ vertical-align: top; } - .version-table td:first-child, - .version-table td:nth-child(2) { + .version-table td:not(:last-child) { white-space: normal; } @@ -100,10 +99,6 @@ font-size: 0.9rem; } - .version-app-detail { - white-space: nowrap; - } - .version-empty p:last-child, .version-setup p:last-child { margin-bottom: 0; @@ -116,8 +111,11 @@

Apps

- Production freshness observed through Segment over the last {{ lookback_days }} days: - Expo runtime for mobile, release tag for TV, and source commit for Roku. + One build per app observed in Segment over the last {{ lookback_days }} days: mobile selects + the highest runtime, TV the highest stable release tag, and Roku its source version. This may + not be the newest install. Freshness compares with the top version seen on the same platform, + or the source commit for Roku—not a store release. Only iOS has a + public Apple lookup (US), which may lag App Store Connect; other stores are not checked.

@@ -143,7 +141,7 @@

App data is unavailable

{{ tab.label }} {{ tab.row_count }} {% if tab.outdated_count %} - {{ tab.outdated_count }} + {{ tab.outdated_count }} {% endif %} {% endfor %} @@ -163,37 +161,50 @@

App data is unavailable

Church App + Seen build + {% if tab.key == "ios" %}Apple lookup (US){% endif %} {{ "Expo Runtime" if tab.key in ("ios", "android") else "Release" if tab.key in ("amazon", "androidtv", "tv", "tvos") else "Commit" if tab.key == "roku" else "Version" }} Status - Seen - Users - Events {% for row in tab.rows %} - {{ "Demo" if row.church == "apollos_demo" else row.church }}
- {{ row.bundle_id }} + {{ row.church }} + {{ row.application_name }}
{{ row.bundle_id }} - {{ row.application_name }}
- Observed {{ row.app_version or "unknown" }} - {% if row.latest_app_version_source == "app_store" and row.latest_app_version != row.app_version %} -
- App Store (live) {{ row.latest_app_version }} - {% endif %} + {{ row.app_version or "unknown" }}
+ Last seen {{ row.latest_seen_display or "unknown" }} + + {% if tab.key == "ios" %} + + {% if row.latest_app_version_source == "app_store" %} + {{ row.latest_app_version }} + {% else %} + Not available + {% endif %} + {% if row.store_checked_display %} +
Checked {{ row.store_checked_display }} + {% endif %} + + {% endif %} + + {{ row.freshness_display or "TBD" }}
+ + {% if row.version_status_label == "Unverified" %} + Comparison unavailable + {% else %} + {{ "Source target" if tab.key == "roku" else "Top seen" }} {{ row.comparison_display or "unknown" }} + {% endif %} + - {{ row.freshness_display or row.apollos_version or "TBD" }} - - {{ row.version_status_label or ("Outdated" if row.is_outdated else "Current") }} + + {{ row.version_status_label or "Unverified" }} - {{ row.latest_seen_display or "unknown" }} - {{ row.user_count }} - {{ row.event_count }} {% endfor %} diff --git a/tests/test_app_versions.py b/tests/test_app_versions.py index 652666e..88a34ae 100644 --- a/tests/test_app_versions.py +++ b/tests/test_app_versions.py @@ -132,6 +132,14 @@ def test_annotates_outdated_apps_by_platform_latest_runtime(self): "version_source": "runtime", "latest_seen_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc), }, + { + "church": "bad-runtime", + "apollos_platform": "ios", + "application_name": "Bad Runtime", + "bundle_id": "com.bad", + "apollos_version": "v999", + "latest_seen_at": datetime(2026, 5, 3, 12, 0, tzinfo=timezone.utc), + }, { "church": "tv-church", "apollos_platform": "tvos", @@ -191,11 +199,15 @@ def test_annotates_outdated_apps_by_platform_latest_runtime(self): ) annotated = app_versions._annotate_version_status( selected, - {"roku_revision_statuses": {rows[-1]["source_revision"]: "behind"}}, + { + "roku_revision_statuses": {rows[-1]["source_revision"]: "behind"}, + "roku_target_revision": "deadbeefabcdef0", + }, ) one_church = next(row for row in annotated if row["church"] == "one-church") two_church = next(row for row in annotated if row["church"] == "two-church") + bad_runtime = next(row for row in annotated if row["church"] == "bad-runtime") tv_church = next(row for row in annotated if row["church"] == "tv-church") old_tv_church = next(row for row in annotated if row["church"] == "old-tv-church") new_tv_church = next(row for row in annotated if row["church"] == "new-tv-church") @@ -203,19 +215,28 @@ def test_annotates_outdated_apps_by_platform_latest_runtime(self): roku_church = next(row for row in annotated if row["church"] == "roku-church") self.assertTrue(one_church["is_outdated"]) self.assertEqual(one_church["freshness_display"], "97") + self.assertEqual(one_church["version_status_label"], "Behind top seen") self.assertFalse(two_church["is_outdated"]) - self.assertEqual(tv_church["version_status_label"], "TBD") + self.assertEqual(bad_runtime["version_status_label"], "Unverified") + self.assertEqual(tv_church["version_status_label"], "Unverified") self.assertTrue(old_tv_church["is_outdated"]) self.assertEqual(old_tv_church["freshness_display"], "v2026.05.01.00") self.assertFalse(new_tv_church["is_outdated"]) self.assertFalse(unknown_platform["is_outdated"]) + self.assertEqual(unknown_platform["version_status_label"], "Unverified") self.assertTrue(roku_church["is_outdated"]) + self.assertEqual(roku_church["version_status_label"], "Behind source") + for status, label in (("identical", "At source"), ("ahead", "Ahead of source")): + checked = app_versions._annotate_version_status( + [rows[-1]], {"roku_revision_statuses": {rows[-1]["source_revision"]: status}} + ) + self.assertEqual(checked[0]["version_status_label"], label) self.assertEqual(roku_church["freshness_display"], "ba95e2f") self.assertEqual(annotated[0]["church"], "one-church") self.assertTrue(app_versions._revisions_match("abcdef123456", "abcdef1")) self.assertFalse(app_versions._revisions_match("abcdef123456", "abc")) - def test_annotates_outdated_apps_by_app_store_version(self): + def test_store_lookup_does_not_claim_a_build_is_outdated(self): rows = [ { "church": "bayside", @@ -290,6 +311,7 @@ def test_enriches_app_store_versions_by_bundle_id(self): self.assertEqual(bayside["latest_app_version"], "5.20.30") self.assertEqual(bayside["latest_app_version_source"], "app_store") self.assertEqual(bayside["latest_app_version_seen_at"], "2026-04-14T16:34:35Z") + self.assertIsNotNone(bayside["store_checked_display"]) self.assertEqual(android["latest_app_version"], "1.0.0") self.assertEqual(android["latest_app_version_source"], "observed") @@ -311,8 +333,9 @@ def test_looks_up_all_app_store_bundles(self): "_fetch_app_store_versions", return_value={}, ) as fetch_app_store_versions: - app_versions._enrich_app_store_versions(rows) + enriched = app_versions._enrich_app_store_versions(rows) + self.assertTrue(all(row.get("store_checked_display") for row in enriched)) lookup_bundle_ids = fetch_app_store_versions.call_args.args[0] self.assertEqual(len(lookup_bundle_ids), app_versions.APP_STORE_LOOKUP_LIMIT + 1) self.assertIn(f"com.example.{app_versions.APP_STORE_LOOKUP_LIMIT}", lookup_bundle_ids) @@ -464,13 +487,27 @@ def test_selects_highest_observed_version_instead_of_most_recent_event(self): }, ] rows.append({**rows[0], "apollos_version": "999", "deployment_track": "internal"}) + rows.append({**rows[0], "apollos_version": "v999", "app_version": "1.0.99"}) + rows.append( + { + **rows[2], + "church": "only_bad", + "bundle_id": "com.apollos.bad", + "apollos_version": "v999", + } + ) selected = app_versions._select_latest_observed_versions(rows) grow_church = next(row for row in selected if row["church"] == "grow_church") - self.assertEqual(len(selected), 2) + only_bad = next(row for row in selected if row["church"] == "only_bad") + self.assertEqual(len(selected), 3) self.assertEqual(grow_church["apollos_version"], "97") self.assertEqual(grow_church["app_version"], "1.0.31") + self.assertEqual( + app_versions._annotate_version_status([only_bad])[0]["version_status_label"], + "Unverified", + ) def test_app_identity_uses_bundle_when_application_name_changes(self): rows = [ @@ -637,7 +674,7 @@ def test_builds_query_from_discovered_segment_columns(self): self.assertIn("app_identity_events AS", query) self.assertIn("display_churches AS", query) self.assertIn("version_observations AS", query) - self.assertIn("app_totals AS", query) + self.assertNotIn("app_totals AS", query) self.assertIn("MAX(events.seen_at) AS latest_seen_at", query) self.assertIn("GROUP BY app_identity_key", query) self.assertIn("USING (app_identity_key)", query) @@ -703,13 +740,11 @@ def test_apps_route_renders_platform_tabs(self): "app_version": "1.0.0", "latest_app_version": "1.0.1", "latest_app_version_source": "app_store", - "latest_app_version_source_label": "App Store", + "store_checked_display": "2026-05-12 10:15 AM EDT", "apollos_platform": "ios", "apollos_version": "97", - "latest_apollos_version": "101", - "source_display": "v2026.05.12.00 (abc1234)", "is_outdated": True, - "latest_seen_display": "2026-05-12 10:00 AM EDT", + "latest_seen_at": "2026-05-12 10:00 AM EDT", "user_count": 5, "event_count": 10, }, @@ -720,17 +755,26 @@ def test_apps_route_renders_platform_tabs(self): "app_version": "1.0.0", "latest_app_version": "1.0.0", "latest_app_version_source": "observed", - "latest_app_version_source_label": "Observed", "apollos_platform": "android", "apollos_version": "97", - "latest_apollos_version": "97", - "source_display": "TBD", "is_outdated": False, - "latest_seen_display": "2026-05-12 10:00 AM EDT", + "latest_seen_at": "2026-05-12 10:00 AM EDT", "user_count": 7, "event_count": 12, }, ] + rows.append( + { + "church": "three-church", + "bundle_id": "com.three", + "application_name": "Three Church", + "app_version": "1.0.2", + "store_checked_display": "2026-05-12 10:16 AM EDT", + "apollos_platform": "ios", + "apollos_version": "101", + } + ) + rows = app_versions._annotate_version_status(rows) context = { "status": "ready", "status_label": "Ready", @@ -752,21 +796,18 @@ def test_apps_route_renders_platform_tabs(self): self.assertIn('data-version-tab="ios"', body) self.assertIn('data-version-tab="android"', body) self.assertIn('id="version-panel-ios"', body) - self.assertNotIn("Latest observed", body) self.assertIn("One Church", body) - self.assertNotIn("Latest App", body) - self.assertNotIn("Observed Version", body) + self.assertIn("Seen build", body) + self.assertIn("Apple lookup (US)", body) self.assertIn("Expo Runtime", body) - self.assertNotIn("Source", body) - self.assertIn("1.0.1", body) - self.assertIn("App Store (live) 1.0.1", body) - self.assertNotIn("App Store (live) 1.0.0", body) + self.assertIn("Status", body) + unavailable = body[body.index("com.three") : body.index("", body.index("com.three"))] + self.assertIn("Not available", unavailable) + self.assertIn("Checked 2026-05-12 10:16 AM EDT", unavailable) self.assertIn("97", body) self.assertIn("Two Church", body) - self.assertNotIn("Platform", body) - self.assertNotIn("Latest Observed", body) - def test_preview_shows_demo_label_and_public_app_store_version(self): + def test_preview_shows_church_slug_and_distinguishes_seen_from_apple_lookup(self): row = { "church": "apollos_demo", "bundle_id": "com.differential.apollospreview", @@ -774,21 +815,40 @@ def test_preview_shows_demo_label_and_public_app_store_version(self): "app_version": "1.0.0", "latest_app_version": "1.40", "latest_app_version_source": "app_store", + "store_checked_display": "2026-09-25 08:45 PM EDT", "apollos_platform": "ios", - "freshness_display": "106", + "apollos_version": "106", } + rows = app_versions._annotate_version_status( + [ + row, + { + **row, + "bundle_id": "com.example.invalid", + "application_name": "Bad App", + "apollos_version": "v999", + }, + ] + ) context = { "status": "ready", - "rows": [row], - "platform_tabs": app_versions.build_platform_tabs([row]), + "rows": rows, + "platform_tabs": app_versions.build_platform_tabs(rows), "lookback_days": 30, } with patch.object(app_module, "get_app_versions_context", return_value=context): body = self.client.get("/apps").get_data(as_text=True) - self.assertIn("Demo", body) - self.assertIn("Observed 1.0.0", body) - self.assertIn("App Store (live) 1.40", body) - self.assertNotIn("apollos_demo", body) + self.assertIn("apollos_demo", body) + self.assertIn("Apollos Preview", body) + self.assertIn("Last seen unknown", body) + self.assertIn("Checked 2026-09-25 08:45 PM EDT", body) + self.assertIn("Top seen", body) + bad_start = body.index("com.example.invalid") + bad_row = body[bad_start : body.index("", bad_start)] + self.assertIn("Comparison unavailable", bad_row) + self.assertNotIn("Top seen 106", bad_row) + self.assertIn("1.40", body) + self.assertNotIn("App Store (live)", body) if __name__ == "__main__":