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

1import json 

2import logging 

3import re 

4import time 

5import uuid 

6from datetime import UTC, datetime 

7from functools import lru_cache 

8from typing import Any 

9 

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 

17 

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 

45 

46logger = logging.getLogger(__name__) 

47 

48_start_time = time.monotonic() 

49 

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} 

57 

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} 

65 

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} 

71 

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} 

77 

78_OTA_BOUNDARY = "COUCHERS_OTA_BOUNDARY" 

79 

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}") 

82 

83 

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 ) 

94 

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") 

101 

102 

103def _native_ota_manifest_url(*, cdn_root: str, version: str, platform: str) -> str: 

104 return f"{cdn_root}/{version}/{platform}/manifest" 

105 

106 

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 ) 

120 

121 

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() 

133 

134 

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) 

158 

159 

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 

168 

169 

170class Bugs(bugs_pb2_grpc.BugsServicer): 

171 def _version(self) -> str: 

172 return config.VERSION 

173 

174 def Version(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> bugs_pb2.VersionInfo: 

175 return bugs_pb2.VersionInfo(version=self._version()) 

176 

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") 

182 

183 repo = config.BUG_TOOL_GITHUB_REPO 

184 auth = (config.BUG_TOOL_GITHUB_USERNAME, config.BUG_TOOL_GITHUB_TOKEN) 

185 

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

191 

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 ) 

210 

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"] 

223 

224 json_body = {"title": issue_title, "body": issue_body, "labels": issue_labels} 

225 

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") 

229 

230 issue_number = r.json()["number"] 

231 

232 return bugs_pb2.ReportBugRes( 

233 bug_id=f"#{issue_number}", bug_url=f"https://github.com/{repo}/issues/{issue_number}" 

234 ) 

235 

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() 

238 

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 ) 

245 

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 ) 

253 

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")]) 

273 

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) 

278 

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 ) 

285 

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) 

291 

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") 

297 

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") 

304 

305 occurred = info.occurred.ToDatetime(tzinfo=UTC) if info.HasField("occurred") else now() 

306 

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 ) 

319 

320 if events: 

321 session.execute(insert(EventLog), events) 

322 

323 return empty_pb2.Empty() 

324 

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) 

345 

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 ) 

362 

363 _observe_native_check_metrics(request, info, decision, checked_at, banned=banned) 

364 

365 if context.is_logged_in(): 

366 session.add(NativeClientUser(eas_client_id=info.eas_client_id, user_id=context.user_id)) 

367 

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) 

379 

380 def GeolocationSearchInfo( 

381 self, request: bugs_pb2.GeolocationSearchInfoReq, context: CouchersContext, session: Session 

382 ) -> empty_pb2.Empty: 

383 return empty_pb2.Empty() 

384 

385 def GeolocationClickInfo( 

386 self, request: bugs_pb2.GeolocationClickInfoReq, context: CouchersContext, session: Session 

387 ) -> empty_pb2.Empty: 

388 return empty_pb2.Empty() 

389 

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 

406 

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()