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

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

47 

48logger = logging.getLogger(__name__) 

49 

50_start_time = time.monotonic() 

51 

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} 

59 

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} 

67 

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} 

73 

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} 

79 

80_OTA_BOUNDARY = "COUCHERS_OTA_BOUNDARY" 

81 

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

84 

85 

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 ) 

96 

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

103 

104 

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

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

107 

108 

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 ) 

122 

123 

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

135 

136 

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) 

160 

161 

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 

170 

171 

172class Bugs(bugs_pb2_grpc.BugsServicer): 

173 def _version(self) -> str: 

174 return config.VERSION 

175 

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

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

178 

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

184 

185 repo = config.BUG_TOOL_GITHUB_REPO 

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

187 

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

193 

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 ) 

212 

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

225 

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

227 

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

231 

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

233 

234 return bugs_pb2.ReportBugRes( 

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

236 ) 

237 

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

240 

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 ) 

247 

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 ) 

255 

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

275 

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) 

280 

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 ) 

287 

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) 

293 

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) 

301 

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

307 

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

314 

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

316 

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 ) 

329 

330 if events: 

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

332 

333 return empty_pb2.Empty() 

334 

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) 

355 

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 ) 

372 

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

374 

375 if context.is_logged_in(): 

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

377 

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) 

389 

390 def GeolocationSearchInfo( 

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

392 ) -> empty_pb2.Empty: 

393 return empty_pb2.Empty() 

394 

395 def GeolocationClickInfo( 

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

397 ) -> empty_pb2.Empty: 

398 return empty_pb2.Empty() 

399 

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 

416 

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