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