Coverage for app/backend/src/couchers/servicers/bugs.py: 99%
163 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import json
2import logging
3import re
4import time
5import uuid
6from datetime import UTC, datetime
7from functools import lru_cache
8from typing import Any
10import grpc
11import requests
12from google.protobuf import empty_pb2, struct_pb2
13from sqlalchemy import insert, select
14from sqlalchemy.dialects.postgresql import insert as pg_insert
15from sqlalchemy.orm import Session
16from sqlalchemy.sql import func
18from couchers import sentry, urls
19from couchers.config import config
20from couchers.constants import STABLE_THRESHOLD_SECONDS
21from couchers.context import CouchersContext
22from couchers.metrics import (
23 observe_native_banned_bundle_hit,
24 observe_native_binary_age,
25 observe_native_bundle_age,
26 observe_native_client_checkin,
27 observe_native_ota_manifest_request,
28 observe_native_update_decision,
29)
30from couchers.middleware.descriptor_pool import get_descriptors_pb
31from couchers.models import NativeClientUser, User
32from couchers.models.logging import EventLog, EventSource, ExperimentExposure, ExposureSource
33from couchers.models.ota import OTAPackage, OTAPlatform
34from couchers.native_updates import (
35 NativeClientInfo,
36 Severity,
37 UpdateAction,
38 UpdateCause,
39 client_info_from_request,
40 decide_native_update,
41)
42from couchers.proto import bugs_pb2, bugs_pb2_grpc
43from couchers.proto.google.api import httpbody_pb2
44from couchers.utils import now
46logger = logging.getLogger(__name__)
48_start_time = time.monotonic()
50updateaction2api = {
51 UpdateAction.unspecified: bugs_pb2.NATIVE_UPDATE_ACTION_UNSPECIFIED,
52 UpdateAction.none: bugs_pb2.NATIVE_UPDATE_ACTION_NONE,
53 UpdateAction.ota: bugs_pb2.NATIVE_UPDATE_ACTION_OTA,
54 UpdateAction.store: bugs_pb2.NATIVE_UPDATE_ACTION_STORE,
55 UpdateAction.reinstall: bugs_pb2.NATIVE_UPDATE_ACTION_REINSTALL,
56}
58api2updateaction = {
59 bugs_pb2.NATIVE_UPDATE_ACTION_UNSPECIFIED: UpdateAction.unspecified,
60 bugs_pb2.NATIVE_UPDATE_ACTION_NONE: UpdateAction.none,
61 bugs_pb2.NATIVE_UPDATE_ACTION_OTA: UpdateAction.ota,
62 bugs_pb2.NATIVE_UPDATE_ACTION_STORE: UpdateAction.store,
63 bugs_pb2.NATIVE_UPDATE_ACTION_REINSTALL: UpdateAction.reinstall,
64}
66updatecause2api = {
67 UpdateCause.unspecified: bugs_pb2.NATIVE_UPDATE_CAUSE_UNSPECIFIED,
68 UpdateCause.age: bugs_pb2.NATIVE_UPDATE_CAUSE_AGE,
69 UpdateCause.banned: bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED,
70}
72api2updatecause = {
73 bugs_pb2.NATIVE_UPDATE_CAUSE_UNSPECIFIED: UpdateCause.unspecified,
74 bugs_pb2.NATIVE_UPDATE_CAUSE_AGE: UpdateCause.age,
75 bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED: UpdateCause.banned,
76}
78_OTA_BOUNDARY = "COUCHERS_OTA_BOUNDARY"
80# Validate before building a link to keep a client-supplied string out of the issue markdown.
81_SENTRY_REPLAY_ID_RE = re.compile(r"[0-9a-f]{32}")
84def _ota_multipart_body(field_name: str, content: dict[str, Any]) -> bytes:
85 # Expo Updates protocol v1 multipart/mixed framing. field_name is "manifest" for
86 # an update or "directive" for a noUpdateAvailable/rollBackToEmbedded directive.
87 def part(name: str, body: str, content_type: str) -> str:
88 return (
89 f"--{_OTA_BOUNDARY}\r\n"
90 f'content-disposition: form-data; name="{name}"\r\n'
91 f"content-type: {content_type}\r\n\r\n"
92 f"{body}\r\n"
93 )
95 body = (
96 part(field_name, json.dumps(content), "application/json; charset=utf-8")
97 + part("extensions", json.dumps({"assetRequestHeaders": {}}), "application/json")
98 + f"--{_OTA_BOUNDARY}--\r\n"
99 )
100 return body.encode("utf-8")
103def _native_ota_manifest_url(*, cdn_root: str, version: str, platform: str) -> str:
104 return f"{cdn_root}/{version}/{platform}/manifest"
107def _is_update_id_banned(session: Session, info: NativeClientInfo) -> bool:
108 if not info.update_id or info.platform not in OTAPlatform.__members__:
109 return False
110 return (
111 session.execute(
112 select(OTAPackage.id)
113 .where(OTAPackage.platform == OTAPlatform[info.platform])
114 .where(OTAPackage.manifest_id == info.update_id)
115 .where(OTAPackage.banned_at.is_not(None))
116 .limit(1)
117 ).scalar_one_or_none()
118 is not None
119 )
122def _newest_non_banned_ota_package(session: Session, platform: str, fingerprint: str) -> OTAPackage | None:
123 if platform not in OTAPlatform.__members__ or not fingerprint:
124 return None
125 return session.execute(
126 select(OTAPackage)
127 .where(OTAPackage.platform == OTAPlatform[platform])
128 .where(OTAPackage.fingerprint == fingerprint)
129 .where(OTAPackage.banned_at.is_(None))
130 .order_by(OTAPackage.manifest_created_at.desc(), OTAPackage.id.desc())
131 .limit(1)
132 ).scalar_one_or_none()
135def _observe_native_check_metrics(
136 request: bugs_pb2.CheckNativeStatusReq,
137 info: NativeClientInfo,
138 decision: Any,
139 now: datetime,
140 *,
141 banned: bool,
142) -> None:
143 if info.binary_created_at is not None:
144 observe_native_binary_age(info.platform, (now - info.binary_created_at).total_seconds())
145 if info.bundle_created_at is not None:
146 observe_native_bundle_age(info.platform, info.is_ota_launch, (now - info.bundle_created_at).total_seconds())
147 observe_native_update_decision(info.platform, decision.action.name, decision.severity.name)
148 observe_native_client_checkin(
149 platform=info.platform,
150 is_ota_launch=info.is_ota_launch,
151 embedded_display_version=request.embedded_display_version,
152 embedded_runtime_version=info.runtime_version,
153 ota_display_version=request.running_display_version if info.is_ota_launch else "",
154 ota_update_id=info.update_id or "",
155 )
156 if banned:
157 observe_native_banned_bundle_hit(info.platform)
160@lru_cache(maxsize=64)
161def _fetch_signed_manifest(url: str) -> tuple[str, bytes]:
162 # The publish job signs each manifest and uploads it under its immutable version, so the
163 # bytes never change once published: fetch once, cache forever, and serve them (signature
164 # and all) untouched so the on-device signature check sees exactly what was signed.
165 response = requests.get(url, timeout=10)
166 response.raise_for_status()
167 return response.headers["content-type"], response.content
170class Bugs(bugs_pb2_grpc.BugsServicer):
171 def _version(self) -> str:
172 return config.VERSION
174 def Version(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> bugs_pb2.VersionInfo:
175 return bugs_pb2.VersionInfo(version=self._version())
177 def ReportBug(
178 self, request: bugs_pb2.ReportBugReq, context: CouchersContext, session: Session
179 ) -> bugs_pb2.ReportBugRes:
180 if not config.BUG_TOOL_ENABLED:
181 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "bug_tool_disabled")
183 repo = config.BUG_TOOL_GITHUB_REPO
184 auth = (config.BUG_TOOL_GITHUB_USERNAME, config.BUG_TOOL_GITHUB_TOKEN)
186 if context.is_logged_in():
187 username = session.execute(select(User.username).where(User.id == context.user_id)).scalar_one()
188 user_details = f"[@{username}]({urls.user_link(username=username)}) ({context.user_id})"
189 else:
190 user_details = "<not logged in>"
192 diagnostics_lines = [
193 f"**Backend version**: `{self._version()}`",
194 f"**Frontend version**: `{request.frontend_version}`",
195 f"**User Agent**: `{request.user_agent}`",
196 f"**Locale**: `{context.localization.preferred_locale}`",
197 f"**Screen resolution**: {request.screen_resolution.width}x{request.screen_resolution.height}",
198 f"**Page**: {request.page}",
199 f"**User**: {user_details} / `{(context._sofa or '')[:12]}`",
200 ]
201 if context.is_logged_in():
202 diagnostics_lines.append(
203 "**Sentry (this user)**: "
204 f"{sentry.frontend_user_issues_link(user_id=context.user_id, reported_at=now())}"
205 )
206 if _SENTRY_REPLAY_ID_RE.fullmatch(request.sentry_replay_id):
207 diagnostics_lines.append(
208 f"**Session replay**: {sentry.frontend_replay_link(replay_id=request.sentry_replay_id)}"
209 )
211 issue_title = request.subject
212 issue_body = (
213 f"# {request.subject}\n"
214 f"## Description\n"
215 f"{request.description}\n"
216 f"\n"
217 f"## Results\n"
218 f"{request.results}\n"
219 f"\n"
220 f"## Diagnostics\n" + "\n".join(diagnostics_lines)
221 )
222 issue_labels = ["bug: triage needed"]
224 json_body = {"title": issue_title, "body": issue_body, "labels": issue_labels}
226 r = requests.post(f"https://api.github.com/repos/{repo}/issues", auth=auth, json=json_body)
227 if not r.status_code == 201:
228 context.abort_with_error_code(grpc.StatusCode.INTERNAL, "bug_tool_request_failed")
230 issue_number = r.json()["number"]
232 return bugs_pb2.ReportBugRes(
233 bug_id=f"#{issue_number}", bug_url=f"https://github.com/{repo}/issues/{issue_number}"
234 )
236 def Status(self, request: bugs_pb2.StatusReq, context: CouchersContext, session: Session) -> bugs_pb2.StatusRes:
237 coucher_count = session.execute(select(func.count()).select_from(User).where(User.is_visible)).scalar_one()
239 return bugs_pb2.StatusRes(
240 nonce=request.nonce,
241 version=self._version(),
242 coucher_count=coucher_count,
243 stable=time.monotonic() - _start_time >= STABLE_THRESHOLD_SECONDS,
244 )
246 def GetDescriptors(
247 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
248 ) -> httpbody_pb2.HttpBody:
249 return httpbody_pb2.HttpBody(
250 content_type="application/octet-stream",
251 data=get_descriptors_pb(),
252 )
254 def GetNativeUpdateManifest(
255 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
256 ) -> httpbody_pb2.HttpBody:
257 platform = context.get_header("expo-platform") or ""
258 fingerprint = context.get_header("expo-runtime-version") or ""
259 eas_client_id = uuid.UUID(context.get_header("eas-client-id") or "")
260 if context.get_boolean_value("log_native_ota_requests", False):
261 logger.info(
262 "OTA GetNativeUpdateManifest: platform=%s fingerprint=%s eas_client_id=%s "
263 "content_type=%r headers=%s body=%r",
264 platform,
265 fingerprint,
266 eas_client_id,
267 request.content_type,
268 dict(context.headers),
269 request.data,
270 )
271 # Expo rejects the manifest without these; Envoy forwards them as HTTP response headers.
272 context.set_response_headers([("expo-protocol-version", "1"), ("expo-sfv-version", "0")])
274 # Newest non-banned bundle for the build's fingerprint, by manifest createdAt. The device's
275 # selection policy only applies it if it's newer than what it's running, so a stale store build
276 # self-heals while a newer one keeps its embedded bundle.
277 package = _newest_non_banned_ota_package(session, platform, fingerprint)
279 if package is None:
280 observe_native_ota_manifest_request(platform, "no_match" if not fingerprint else "no_update")
281 return httpbody_pb2.HttpBody(
282 content_type=f"multipart/mixed; boundary={_OTA_BOUNDARY}",
283 data=_ota_multipart_body("directive", {"type": "noUpdateAvailable"}),
284 )
286 cdn_root = context.get_string_value("native_ota_cdn_root", "https://cdn.couchers.org/native/ota")
287 url = _native_ota_manifest_url(cdn_root=cdn_root, version=package.version, platform=platform)
288 content_type, body = _fetch_signed_manifest(url)
289 observe_native_ota_manifest_request(platform, "served")
290 return httpbody_pb2.HttpBody(content_type=content_type, data=body)
292 def ReportDiagnostics(
293 self, request: bugs_pb2.ReportDiagnosticsReq, context: CouchersContext, session: Session
294 ) -> empty_pb2.Empty:
295 if len(request.infos) > 100:
296 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "too_many_diagnostic_infos")
298 events = []
299 for info in request.infos:
300 try:
301 properties = json.loads(info.properties_json)
302 except json.JSONDecodeError, ValueError:
303 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_diagnostics_json")
305 occurred = info.occurred.ToDatetime(tzinfo=UTC) if info.HasField("occurred") else now()
307 events.append(
308 {
309 "event_type": info.tag,
310 "user_id": context._user_id,
311 "sofa": context._sofa,
312 "version": request.frontend_version,
313 "properties": properties,
314 "value": info.value,
315 "source": EventSource.frontend,
316 "occurred": occurred,
317 }
318 )
320 if events:
321 session.execute(insert(EventLog), events)
323 return empty_pb2.Empty()
325 def CheckNativeStatus(
326 self, request: bugs_pb2.CheckNativeStatusReq, context: CouchersContext, session: Session
327 ) -> bugs_pb2.CheckNativeStatusRes:
328 info = client_info_from_request(request)
329 logger.info(
330 "CheckNativeStatus: user_id=%s install_id=%s eas_client_id=%s platform=%s app_version=%s "
331 "running_debug_version_ota=%s update_id=%s launch_source=%s debug_json=%s",
332 context._user_id,
333 request.install_id,
334 info.eas_client_id,
335 request.platform,
336 request.app_version,
337 request.running_debug_version_ota,
338 request.update_id,
339 request.launch_source,
340 request.debug_json,
341 )
342 checked_at = now()
343 banned = _is_update_id_banned(session, info)
344 decision = decide_native_update(context, info, checked_at, banned=banned)
346 # An OTA block with no newer bundle to serve would loop the client on the block screen
347 # forever, so refuse to serve it: raise (pages via Sentry) and the client, which ignores
348 # these errors, stays unblocked. Store blocks aren't checkable — no record of the latest build.
349 if decision.action == UpdateAction.ota and decision.severity == Severity.block:
350 newest = _newest_non_banned_ota_package(session, info.platform, info.runtime_version)
351 newer_available = newest is not None and (
352 info.bundle_created_at is None or newest.manifest_created_at > info.bundle_created_at
353 )
354 if not newer_available:
355 raise Exception(
356 "CheckNativeStatus would force an OTA update with no newer bundle to move to "
357 f"(platform={info.platform!r} fingerprint={info.runtime_version!r} "
358 f"cause={decision.cause.name} update_id={info.update_id!r} "
359 f"running_bundle_created_at={info.bundle_created_at} "
360 f"newest_non_banned_created_at={None if newest is None else newest.manifest_created_at})"
361 )
363 _observe_native_check_metrics(request, info, decision, checked_at, banned=banned)
365 if context.is_logged_in():
366 session.add(NativeClientUser(eas_client_id=info.eas_client_id, user_id=context.user_id))
368 # message and link_text intentionally left empty for the standard cases — the client
369 # hardcodes those. The fields are reserved for special-case overrides; nothing in the
370 # current decision logic populates them.
371 update_info = bugs_pb2.NativeUpdateInfo(
372 action=updateaction2api[decision.action],
373 required=decision.severity != Severity.none,
374 cause=updatecause2api[decision.cause],
375 )
376 if decision.act_by is not None:
377 update_info.act_by.FromDatetime(decision.act_by)
378 return bugs_pb2.CheckNativeStatusRes(update_info=update_info)
380 def GeolocationSearchInfo(
381 self, request: bugs_pb2.GeolocationSearchInfoReq, context: CouchersContext, session: Session
382 ) -> empty_pb2.Empty:
383 return empty_pb2.Empty()
385 def GeolocationClickInfo(
386 self, request: bugs_pb2.GeolocationClickInfoReq, context: CouchersContext, session: Session
387 ) -> empty_pb2.Empty:
388 return empty_pb2.Empty()
390 def EvaluateFeatureFlag(
391 self, request: bugs_pb2.EvaluateFeatureFlagReq, context: CouchersContext, session: Session
392 ) -> bugs_pb2.EvaluateFeatureFlagRes:
393 # None default: an unconfigured flag comes back as None and the value field is left unset, so
394 # the frontend applies its own in-code default. get_object_value is the generic typed
395 # accessor; like every value method it fires exposure/usage logging as a side effect, here
396 # for exactly the one flag the client is reading.
397 value: Any = context.get_object_value(request.flag_key, None)
398 res = bugs_pb2.EvaluateFeatureFlagRes()
399 if value is not None:
400 # google.protobuf.Value has no direct constructor from a Python value; round-trip
401 # through a Struct, which knows how to encode bool/number/str/list/dict.
402 holder = struct_pb2.Struct()
403 holder["value"] = value
404 res.value.CopyFrom(holder.fields["value"])
405 return res
407 def LogExperimentExposure(
408 self, request: bugs_pb2.LogExperimentExposureReq, context: CouchersContext, session: Session
409 ) -> empty_pb2.Empty:
410 # need a logged-in user to attribute the exposure to
411 if context.is_logged_in():
412 data = {
413 "experiment_name": request.experiment_name,
414 "variation_key": request.variation_key,
415 "variation_name": request.variation_name,
416 "hash_attribute": request.hash_attribute,
417 "hash_value": request.hash_value,
418 "bucket": request.bucket if request.HasField("bucket") else None,
419 "in_experiment": request.in_experiment,
420 "hash_used": request.hash_used if request.HasField("hash_used") else None,
421 "sticky_bucket_used": (request.sticky_bucket_used if request.HasField("sticky_bucket_used") else None),
422 "feature_id": request.feature_id,
423 }
424 session.execute(
425 pg_insert(ExperimentExposure)
426 .values(
427 user_id=context.user_id,
428 experiment_key=request.experiment_key,
429 variation_id=request.variation_id,
430 source=ExposureSource.client,
431 data=data,
432 )
433 .on_conflict_do_nothing(constraint="uq_experiment_exposures_user_exp_var")
434 )
435 return empty_pb2.Empty()