Skip to content

Commit e1afe31

Browse files
aapelivclaude
andcommitted
Add Bugs.CheckNativeStatus diagnostics ping for the mobile app
Adds an open Bugs.CheckNativeStatus RPC that logs an append-only row to a new logging.native_diagnostics table (app/build, OTA bundle, push state, device info, time since last open) and returns a CheckNativeStatusRes the client can use to drive a force-update flow. The mobile app pings on cold start and on each background→foreground transition, throttled to once per 30 minutes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 133e46e commit e1afe31

13 files changed

Lines changed: 773 additions & 8 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Add native_diagnostics table
2+
3+
Revision ID: 0158
4+
Revises: 0157
5+
Create Date: 2026-05-27 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0158"
14+
down_revision = "0157"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.create_table(
21+
"native_diagnostics",
22+
sa.Column("id", sa.BigInteger(), nullable=False),
23+
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
24+
sa.Column("occurred", sa.DateTime(timezone=True), nullable=False),
25+
sa.Column("install_id", sa.String(), nullable=True),
26+
sa.Column("idfv", sa.String(), nullable=True),
27+
sa.Column("android_id", sa.String(), nullable=True),
28+
sa.Column("user_id", sa.BigInteger(), nullable=True),
29+
sa.Column("sofa", sa.String(), nullable=True),
30+
sa.Column("user_state", sa.String(), nullable=True),
31+
sa.Column("app_version", sa.String(), nullable=True),
32+
sa.Column("git_hash", sa.String(), nullable=True),
33+
sa.Column("native_build", sa.String(), nullable=True),
34+
sa.Column("platform", sa.String(), nullable=True),
35+
sa.Column("os_version", sa.String(), nullable=True),
36+
sa.Column("device_name", sa.String(), nullable=True),
37+
sa.Column("locale", sa.String(), nullable=True),
38+
sa.Column("ota_update_id", sa.String(), nullable=True),
39+
sa.Column("runtime_version", sa.String(), nullable=True),
40+
sa.Column("ota_channel", sa.String(), nullable=True),
41+
sa.Column("is_embedded", sa.Boolean(), nullable=True),
42+
sa.Column("ota_created_at", sa.DateTime(timezone=True), nullable=True),
43+
sa.Column("push_permission", sa.String(), nullable=True),
44+
sa.Column("push_token", sa.String(), nullable=True),
45+
sa.Column("time_since_last_open_seconds", sa.Float(), nullable=True),
46+
sa.PrimaryKeyConstraint("id", name=op.f("pk_native_diagnostics")),
47+
schema="logging",
48+
)
49+
op.create_index(
50+
"ix_logging_native_diagnostics_created", "native_diagnostics", ["created"], unique=False, schema="logging"
51+
)
52+
op.create_index(
53+
"ix_logging_native_diagnostics_install_id_created",
54+
"native_diagnostics",
55+
["install_id", "created"],
56+
unique=False,
57+
schema="logging",
58+
)
59+
op.create_index(
60+
"ix_logging_native_diagnostics_user_id_created",
61+
"native_diagnostics",
62+
["user_id", "created"],
63+
unique=False,
64+
schema="logging",
65+
)
66+
67+
68+
def downgrade() -> None:
69+
op.drop_index("ix_logging_native_diagnostics_user_id_created", table_name="native_diagnostics", schema="logging")
70+
op.drop_index("ix_logging_native_diagnostics_install_id_created", table_name="native_diagnostics", schema="logging")
71+
op.drop_index("ix_logging_native_diagnostics_created", table_name="native_diagnostics", schema="logging")
72+
op.drop_table("native_diagnostics", schema="logging")

app/backend/src/couchers/models/logging.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,66 @@ class FeatureUsage(Base, kw_only=True):
186186
Index("ix_logging_feature_usage_user_id_time", "user_id", "time"),
187187
{"schema": "logging"},
188188
)
189+
190+
191+
class NativeDiagnostics(Base, kw_only=True):
192+
"""
193+
Append-only diagnostics pings from the native mobile apps.
194+
195+
One row per ping (typically emitted on app open / foreground). Lets us see which app versions
196+
and OTA bundles are in the wild, push registration state, last-seen times, etc. Distinct from
197+
the generic event_log; this has structured columns so it can be queried directly.
198+
"""
199+
200+
__tablename__ = "native_diagnostics"
201+
202+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
203+
204+
# when the row was inserted into the DB
205+
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
206+
207+
# when the ping was generated client-side (may lag created if the app was offline)
208+
occurred: Mapped[datetime] = mapped_column(DateTime(timezone=True))
209+
210+
# stable-ish device identifiers (see proto for semantics)
211+
install_id: Mapped[str | None] = mapped_column(String, default=None)
212+
idfv: Mapped[str | None] = mapped_column(String, default=None)
213+
android_id: Mapped[str | None] = mapped_column(String, default=None)
214+
215+
# session context, pulled server-side from the request
216+
user_id: Mapped[int | None] = mapped_column(BigInteger, default=None)
217+
sofa: Mapped[str | None] = mapped_column(String, default=None)
218+
# client's view of its auth state: "logged_out" or "authenticated"
219+
user_state: Mapped[str | None] = mapped_column(String, default=None)
220+
221+
# app/build identity
222+
app_version: Mapped[str | None] = mapped_column(String, default=None)
223+
git_hash: Mapped[str | None] = mapped_column(String, default=None)
224+
native_build: Mapped[str | None] = mapped_column(String, default=None)
225+
226+
# platform/device
227+
platform: Mapped[str | None] = mapped_column(String, default=None)
228+
os_version: Mapped[str | None] = mapped_column(String, default=None)
229+
device_name: Mapped[str | None] = mapped_column(String, default=None)
230+
locale: Mapped[str | None] = mapped_column(String, default=None)
231+
232+
# OTA / expo-updates state
233+
ota_update_id: Mapped[str | None] = mapped_column(String, default=None)
234+
runtime_version: Mapped[str | None] = mapped_column(String, default=None)
235+
ota_channel: Mapped[str | None] = mapped_column(String, default=None)
236+
is_embedded: Mapped[bool | None] = mapped_column(Boolean, default=None)
237+
ota_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
238+
239+
# push state; push_token lets us correlate reachability with delivery receipts
240+
push_permission: Mapped[str | None] = mapped_column(String, default=None)
241+
push_token: Mapped[str | None] = mapped_column(String, default=None)
242+
243+
# seconds since the app was last opened (null on a fresh install / first open)
244+
time_since_last_open_seconds: Mapped[float | None] = mapped_column(Float, default=None)
245+
246+
__table_args__ = (
247+
Index("ix_logging_native_diagnostics_created", "created"),
248+
Index("ix_logging_native_diagnostics_install_id_created", "install_id", "created"),
249+
Index("ix_logging_native_diagnostics_user_id_created", "user_id", "created"),
250+
{"schema": "logging"},
251+
)

app/backend/src/couchers/servicers/bugs.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from couchers.context import CouchersContext
1717
from couchers.descriptor_pool import get_descriptors_pb
1818
from couchers.models import User
19-
from couchers.models.logging import EventLog, EventSource
19+
from couchers.models.logging import EventLog, EventSource, NativeDiagnostics
2020
from couchers.proto import bugs_pb2, bugs_pb2_grpc
2121
from couchers.proto.google.api import httpbody_pb2
2222

@@ -190,6 +190,50 @@ def ReportDiagnostics(
190190

191191
return empty_pb2.Empty()
192192

193+
def CheckNativeStatus(
194+
self, request: bugs_pb2.CheckNativeStatusReq, context: CouchersContext, session: Session
195+
) -> bugs_pb2.CheckNativeStatusRes:
196+
occurred = request.occurred.ToDatetime(tzinfo=UTC) if request.HasField("occurred") else datetime.now(UTC)
197+
ota_created_at = request.ota_created_at.ToDatetime(tzinfo=UTC) if request.HasField("ota_created_at") else None
198+
199+
session.add(
200+
NativeDiagnostics(
201+
occurred=occurred,
202+
install_id=request.install_id or None,
203+
idfv=request.idfv or None,
204+
android_id=request.android_id or None,
205+
user_id=context._user_id,
206+
sofa=context._sofa,
207+
user_state=request.user_state or None,
208+
app_version=request.app_version or None,
209+
git_hash=request.git_hash or None,
210+
native_build=request.native_build or None,
211+
platform=request.platform or None,
212+
os_version=request.os_version or None,
213+
device_name=request.device_name or None,
214+
locale=request.locale or None,
215+
ota_update_id=request.ota_update_id or None,
216+
runtime_version=request.runtime_version or None,
217+
ota_channel=request.ota_channel or None,
218+
is_embedded=request.is_embedded,
219+
ota_created_at=ota_created_at,
220+
push_permission=request.push_permission or None,
221+
push_token=request.push_token or None,
222+
time_since_last_open_seconds=(
223+
request.time_since_last_open_seconds if request.HasField("time_since_last_open_seconds") else None
224+
),
225+
)
226+
)
227+
228+
# TODO: decide based on platform/app_version/native_build whether this client is below a
229+
# minimum supported version and should be force-updated.
230+
return bugs_pb2.CheckNativeStatusRes(
231+
update_required=False,
232+
update_available=False,
233+
update_url="",
234+
update_message="",
235+
)
236+
193237
def GeolocationSearchInfo(
194238
self, request: bugs_pb2.GeolocationSearchInfoReq, context: CouchersContext, session: Session
195239
) -> empty_pb2.Empty:

app/backend/src/tests/test_bugs.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from couchers.config import config
1111
from couchers.crypto import random_hex
1212
from couchers.db import session_scope
13-
from couchers.models.logging import EventLog, EventSource
13+
from couchers.models.logging import EventLog, EventSource, NativeDiagnostics
1414
from couchers.proto import bugs_pb2
1515
from couchers.proto.google.api import httpbody_pb2
1616
from tests.fixtures.db import generate_user
@@ -395,6 +395,109 @@ def test_report_diagnostics_frontend_version(db):
395395
assert events[0].version == "abc-def-123"
396396

397397

398+
def _get_native_diagnostics(session):
399+
return session.execute(select(NativeDiagnostics).order_by(NativeDiagnostics.id)).scalars().all()
400+
401+
402+
def test_check_native_status_anonymous(db):
403+
with bugs_session() as bugs:
404+
res = bugs.CheckNativeStatus(
405+
bugs_pb2.CheckNativeStatusReq(
406+
install_id="abc-123",
407+
idfv="idfv-1",
408+
user_state="logged_out",
409+
app_version="1.1.20",
410+
git_hash="deadbeef",
411+
native_build="42",
412+
platform="ios",
413+
os_version="17.4",
414+
device_name="Test iPhone",
415+
locale="en",
416+
ota_update_id="ota-1",
417+
runtime_version="rt-1",
418+
ota_channel="production",
419+
is_embedded=False,
420+
push_permission="granted",
421+
push_token="ExpoToken[xyz]",
422+
)
423+
)
424+
425+
# Force-update decision logic isn't wired yet; for now the response is always permissive.
426+
assert res.update_required is False
427+
assert res.update_available is False
428+
assert res.update_url == ""
429+
assert res.update_message == ""
430+
431+
with session_scope() as session:
432+
rows = _get_native_diagnostics(session)
433+
assert len(rows) == 1
434+
row = rows[0]
435+
assert row.install_id == "abc-123"
436+
assert row.idfv == "idfv-1"
437+
assert row.android_id is None
438+
assert row.user_id is None
439+
assert row.user_state == "logged_out"
440+
assert row.app_version == "1.1.20"
441+
assert row.git_hash == "deadbeef"
442+
assert row.native_build == "42"
443+
assert row.platform == "ios"
444+
assert row.os_version == "17.4"
445+
assert row.device_name == "Test iPhone"
446+
assert row.ota_update_id == "ota-1"
447+
assert row.runtime_version == "rt-1"
448+
assert row.ota_channel == "production"
449+
assert row.is_embedded is False
450+
assert row.push_permission == "granted"
451+
assert row.push_token == "ExpoToken[xyz]"
452+
assert row.time_since_last_open_seconds is None
453+
454+
455+
def test_check_native_status_authenticated(db):
456+
user, token = generate_user()
457+
458+
with bugs_session(token) as bugs:
459+
bugs.CheckNativeStatus(
460+
bugs_pb2.CheckNativeStatusReq(
461+
install_id="abc-123",
462+
user_state="authenticated",
463+
platform="android",
464+
android_id="ssaid-1",
465+
time_since_last_open_seconds=3600.0,
466+
)
467+
)
468+
469+
with session_scope() as session:
470+
rows = _get_native_diagnostics(session)
471+
assert len(rows) == 1
472+
assert rows[0].user_id == user.id
473+
assert rows[0].user_state == "authenticated"
474+
assert rows[0].platform == "android"
475+
assert rows[0].android_id == "ssaid-1"
476+
assert rows[0].time_since_last_open_seconds == pytest.approx(3600.0)
477+
478+
479+
def test_check_native_status_occurred_and_ota_created_at(db):
480+
occurred = timestamp_pb2.Timestamp()
481+
occurred.FromDatetime(datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC))
482+
ota_created = timestamp_pb2.Timestamp()
483+
ota_created.FromDatetime(datetime(2026, 1, 10, 8, 0, 0, tzinfo=UTC))
484+
485+
with bugs_session() as bugs:
486+
bugs.CheckNativeStatus(
487+
bugs_pb2.CheckNativeStatusReq(
488+
install_id="abc-123",
489+
occurred=occurred,
490+
ota_created_at=ota_created,
491+
)
492+
)
493+
494+
with session_scope() as session:
495+
rows = _get_native_diagnostics(session)
496+
assert len(rows) == 1
497+
assert rows[0].occurred == datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC)
498+
assert rows[0].ota_created_at == datetime(2026, 1, 10, 8, 0, 0, tzinfo=UTC)
499+
500+
398501
def _multipart_part_json(body, name):
399502
"""Extract and parse the JSON body of a named part from a multipart/mixed body."""
400503
marker = f'name="{name}"'

app/mobile/app/_layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
2828
import DevSettingsButton from "@/components/DevSettingsButton";
2929
import { hydrateUrlOverrides } from "@/config/urls";
3030
import { AuthProvider, useAuthContext } from "@/features/auth/AuthContext";
31+
import { useNativeDiagnostics } from "@/features/diagnostics/useNativeDiagnostics";
3132
import { useRegisterPushNotifications } from "@/features/notifications/useRegisterPushNotifications";
3233
import { reconfigureApiClient } from "@/service/client";
3334
import { getNotificationPath } from "@/utils/getNotificationPath";
@@ -203,5 +204,6 @@ function useNotificationObserver() {
203204
function PushNotificationsRegistrar() {
204205
useRegisterPushNotifications();
205206
useNotificationObserver();
207+
useNativeDiagnostics();
206208
return null;
207209
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import AsyncStorage from "@react-native-async-storage/async-storage";
2+
import * as Application from "expo-application";
3+
import { Platform } from "react-native";
4+
5+
// A UUID we generate and persist on first launch. It's the primary key for a device install in the
6+
// diagnostics pipeline. It survives app restarts and OTA updates, but resets on reinstall (the
7+
// platform identifiers below cover cross-install correlation where the OS allows it).
8+
const INSTALL_ID_KEY = "diagnostics.installId";
9+
10+
function uuidv4(): string {
11+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
12+
const r = (Math.random() * 16) | 0;
13+
const v = c === "x" ? r : (r & 0x3) | 0x8;
14+
return v.toString(16);
15+
});
16+
}
17+
18+
export async function getInstallId(): Promise<string> {
19+
const existing = await AsyncStorage.getItem(INSTALL_ID_KEY);
20+
if (existing) {
21+
return existing;
22+
}
23+
const id = uuidv4();
24+
await AsyncStorage.setItem(INSTALL_ID_KEY, id);
25+
return id;
26+
}
27+
28+
// Best-effort platform identifiers reported alongside install_id for cross-install correlation.
29+
// - iOS: identifierForVendor persists across reinstall only while another app from our Apple Team
30+
// is installed; otherwise it regenerates.
31+
// - Android: ANDROID_ID (SSAID) is stable across reinstalls of apps signed with the same key and
32+
// resets only on factory reset.
33+
export async function getPlatformDeviceIds(): Promise<{
34+
idfv?: string;
35+
androidId?: string;
36+
}> {
37+
try {
38+
if (Platform.OS === "ios") {
39+
const idfv = await Application.getIosIdForVendorAsync();
40+
return { idfv: idfv ?? undefined };
41+
}
42+
if (Platform.OS === "android") {
43+
return { androidId: Application.getAndroidId() ?? undefined };
44+
}
45+
} catch {
46+
// Identifiers are best-effort; never block a diagnostics ping on them.
47+
}
48+
return {};
49+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import AsyncStorage from "@react-native-async-storage/async-storage";
2+
3+
// The last Expo push token we successfully registered with the backend. Persisted so the
4+
// diagnostics ping can report it (letting the backend correlate push reachability with delivery
5+
// receipts) without re-fetching a token on every app open.
6+
const PUSH_TOKEN_KEY = "diagnostics.pushToken";
7+
8+
export async function setStoredPushToken(token: string): Promise<void> {
9+
await AsyncStorage.setItem(PUSH_TOKEN_KEY, token);
10+
}
11+
12+
export async function getStoredPushToken(): Promise<string | null> {
13+
return AsyncStorage.getItem(PUSH_TOKEN_KEY);
14+
}

0 commit comments

Comments
 (0)