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__":