Coverage for app/backend/src/couchers/metrics.py: 96%

266 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1import threading 

2import time 

3from collections.abc import Callable, Sequence 

4from concurrent.futures import ThreadPoolExecutor 

5from datetime import datetime, timedelta 

6from typing import Any, cast 

7 

8from opentelemetry import trace 

9from prometheus_client import ( 

10 CONTENT_TYPE_LATEST, 

11 CollectorRegistry, 

12 Counter, 

13 Gauge, 

14 Histogram, 

15 exposition, 

16 generate_latest, 

17 multiprocess, 

18) 

19from prometheus_client.registry import CollectorRegistry 

20from sqlalchemy import Engine, and_, select 

21from sqlalchemy.pool import QueuePool 

22from sqlalchemy.sql import distinct, func 

23from sqlalchemy.sql.elements import ColumnElement 

24from sqlalchemy.sql.selectable import Select 

25 

26from couchers import experimentation 

27from couchers.config import config 

28from couchers.db import session_scope 

29from couchers.helpers.completed_profile import has_completed_profile_expression 

30from couchers.materialized_views import ClusterSubscriptionCount 

31from couchers.models import ( 

32 BackgroundJob, 

33 ClientPlatform, 

34 Cluster, 

35 EventOccurrenceAttendee, 

36 HostingStatus, 

37 HostRequest, 

38 Message, 

39 Node, 

40 NodeType, 

41 NonvisibleUserAccessType, 

42 NonvisibleUserState, 

43 Reference, 

44 User, 

45 UserActivity, 

46) 

47from couchers.models.moderation import ( 

48 ModerationAction, 

49 ModerationObjectType, 

50 ModerationQueueItem, 

51 ModerationState, 

52 ModerationTrigger, 

53 ModerationVisibility, 

54) 

55from couchers.models.uploads import PhotoGalleryItem 

56from couchers.perf import PerfResult 

57 

58tracer = trace.get_tracer(__name__) 

59 

60registry: CollectorRegistry = CollectorRegistry() 

61multiprocess.MultiProcessCollector(registry) # type: ignore[no-untyped-call] 

62 

63_INF: float = float("inf") 

64 

65# Dense from 1ms to ~300ms where most calls land, sparse out to 10min for long background jobs. 

66MACHINE_DURATION_SECONDS: tuple[float, ...] = ( 

67 0.001, 

68 0.0025, 

69 0.005, 

70 0.0075, 

71 0.01, 

72 0.015, 

73 0.02, 

74 0.03, 

75 0.04, 

76 0.05, 

77 0.06, 

78 0.075, 

79 0.1, 

80 0.125, 

81 0.15, 

82 0.2, 

83 0.25, 

84 0.3, 

85 0.4, 

86 0.5, 

87 0.75, 

88 1.0, 

89 1.5, 

90 2.0, 

91 3.0, 

92 5.0, 

93 7.5, 

94 10.0, 

95 15.0, 

96 30.0, 

97 60, 

98 120, 

99 300, 

100 600, 

101 _INF, 

102) 

103 

104start_time_gauge: Gauge = Gauge( 

105 "couchers_start_time_seconds", 

106 "Unix timestamp of when the process started", 

107 multiprocess_mode="max", 

108) 

109start_time_gauge.set(time.time()) 

110 

111commit_timestamp_gauge: Gauge = Gauge( 

112 "couchers_commit_timestamp_seconds", 

113 "Unix timestamp of the deployed commit, 0 if not a CI build", 

114 multiprocess_mode="max", 

115) 

116# left at its default of 0 when COMMIT_TIMESTAMP is empty (i.e. not a CI build) 

117if config.COMMIT_TIMESTAMP: 117 ↛ 120line 117 didn't jump to line 120 because the condition on line 117 was always true

118 commit_timestamp_gauge.set(datetime.fromisoformat(config.COMMIT_TIMESTAMP).timestamp()) 

119 

120jobs_duration_histogram: Histogram = Histogram( 

121 "couchers_background_jobs_seconds", 

122 "Durations of background jobs", 

123 labelnames=["job", "status", "attempt", "exception"], 

124 buckets=MACHINE_DURATION_SECONDS, 

125) 

126 

127 

128def observe_in_jobs_duration_histogram( 

129 job_type: str, job_state: str, try_count: int, exception_name: str, duration_s: float 

130) -> None: 

131 jobs_duration_histogram.labels(job_type, job_state, str(try_count), exception_name).observe(duration_s) 

132 

133 

134jobs_queued_histogram: Histogram = Histogram( 

135 "couchers_background_jobs_queued_seconds", 

136 "Time background job spent queued before being picked up", 

137 labelnames=["priority"], 

138 buckets=( 

139 0.01, 

140 0.05, 

141 0.1, 

142 0.5, 

143 1.0, 

144 2.5, 

145 5.0, 

146 10, 

147 20, 

148 30, 

149 40, 

150 50, 

151 60, 

152 90, 

153 120, 

154 180, 

155 240, 

156 300, 

157 360, 

158 420, 

159 480, 

160 540, 

161 600, 

162 720, 

163 900, 

164 1800, 

165 3600, 

166 _INF, 

167 ), 

168) 

169 

170 

171servicer_duration_histogram: Histogram = Histogram( 

172 "couchers_servicer_duration_seconds", 

173 "Durations of processing gRPC calls", 

174 labelnames=["method", "logged_in", "code", "exception"], 

175 buckets=MACHINE_DURATION_SECONDS, 

176) 

177 

178 

179def observe_in_servicer_duration_histogram( 

180 method: str, user_id: Any, status_code: str, exception_type: str, duration_s: float 

181) -> None: 

182 servicer_duration_histogram.labels(method, user_id is not None, status_code, exception_type).observe(duration_s) 

183 

184 

185servicer_setup_errors_counter: Counter = Counter( 

186 "couchers_servicer_setup_errors_total", 

187 "Number of unexpected errors raised during gRPC interceptor setup, before the handler is invoked", 

188 labelnames=["method", "exception"], 

189) 

190 

191 

192def observe_in_servicer_setup_errors_counter(method: str, exception_type: str) -> None: 

193 servicer_setup_errors_counter.labels(method, exception_type).inc() 

194 

195 

196# Per-request resource accounting (see couchers/perf.py), labelled by method only to keep cardinality modest. The 

197# histogram _sum gives the cost rate per endpoint via rate() (DB-seconds/sec, CPU-seconds/sec); the buckets give the 

198# per-call distribution. 

199servicer_db_time_histogram: Histogram = Histogram( 

200 "couchers_servicer_db_time_seconds", 

201 "Time spent in DB cursor execution per gRPC call", 

202 labelnames=["method"], 

203 buckets=MACHINE_DURATION_SECONDS, 

204) 

205servicer_cpu_time_histogram: Histogram = Histogram( 

206 "couchers_servicer_cpu_seconds", 

207 "Backend thread CPU time per gRPC call", 

208 labelnames=["method"], 

209 buckets=MACHINE_DURATION_SECONDS, 

210) 

211# Fibonacci bucket boundaries: roughly exponential, good resolution for an unbounded value 

212servicer_db_query_count_histogram: Histogram = Histogram( 

213 "couchers_servicer_db_query_count", 

214 "Number of SQL statements executed per gRPC call", 

215 labelnames=["method"], 

216 buckets=(1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, _INF), 

217) 

218servicer_db_write_query_count_histogram: Histogram = Histogram( 

219 "couchers_servicer_db_write_query_count", 

220 "Number of INSERT/UPDATE/DELETE statements executed per gRPC call", 

221 labelnames=["method"], 

222 buckets=(1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, _INF), 

223) 

224 

225 

226def observe_in_servicer_perf_histograms(method: str, perf: PerfResult | None) -> None: 

227 if perf is None: 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

228 return 

229 servicer_db_time_histogram.labels(method).observe(perf.db_time_ms / 1000) 

230 servicer_cpu_time_histogram.labels(method).observe(perf.cpu_ms / 1000) 

231 servicer_db_query_count_histogram.labels(method).observe(perf.db_query_count) 

232 servicer_db_write_query_count_histogram.labels(method).observe(perf.db_write_query_count) 

233 

234 

235# Auth/setup phase (everything before the handler body), same db-vs-cpu split as the handler-body histograms above. 

236servicer_setup_db_time_histogram: Histogram = Histogram( 

237 "couchers_servicer_setup_db_time_seconds", 

238 "Time spent in DB cursor execution during the auth/setup phase per gRPC call", 

239 labelnames=["method"], 

240 buckets=MACHINE_DURATION_SECONDS, 

241) 

242servicer_setup_cpu_time_histogram: Histogram = Histogram( 

243 "couchers_servicer_setup_cpu_seconds", 

244 "Backend thread CPU time during the auth/setup phase per gRPC call", 

245 labelnames=["method"], 

246 buckets=MACHINE_DURATION_SECONDS, 

247) 

248 

249 

250def observe_in_servicer_setup_histogram(method: str, perf: PerfResult | None) -> None: 

251 if perf is None: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 return 

253 servicer_setup_db_time_histogram.labels(method).observe(perf.db_time_ms / 1000) 

254 servicer_setup_cpu_time_histogram.labels(method).observe(perf.cpu_ms / 1000) 

255 

256 

257servicer_pool_wait_histogram: Histogram = Histogram( 

258 "couchers_servicer_pool_wait_seconds", 

259 "Time spent waiting to check out a DB connection from the pool per gRPC call", 

260 labelnames=["method"], 

261 buckets=MACHINE_DURATION_SECONDS, 

262) 

263 

264 

265def observe_in_servicer_pool_wait_histogram(method: str, pool_wait_s: float) -> None: 

266 servicer_pool_wait_histogram.labels(method).observe(pool_wait_s) 

267 

268 

269# Separate diagnostic, not part of the additive duration pie: "serialize" runs after the duration window closes. 

270servicer_serde_histogram: Histogram = Histogram( 

271 "couchers_servicer_serde_seconds", 

272 "Protobuf request deserialization / response serialization time per gRPC call", 

273 labelnames=["method", "direction"], 

274 buckets=MACHINE_DURATION_SECONDS, 

275) 

276 

277 

278def observe_in_servicer_serde_histogram(method: str, direction: str, serde_s: float) -> None: 

279 servicer_serde_histogram.labels(method, direction).observe(serde_s) 

280 

281 

282# liveall keeps one series per worker pid (and drops dead workers), so these also show load balance across workers. 

283# Updated from inside each worker since the /metrics scrape runs in the parent, which has neither pool. 

284grpc_in_flight_gauge: Gauge = Gauge( 

285 "couchers_grpc_in_flight", 

286 "Outstanding gRPC calls (running plus queued for a server thread), per worker process", 

287 multiprocess_mode="liveall", 

288) 

289grpc_threadpool_queue_depth_gauge: Gauge = Gauge( 

290 "couchers_grpc_threadpool_queue_depth", 

291 "gRPC calls queued waiting for a free server thread, per worker process", 

292 multiprocess_mode="liveall", 

293) 

294db_pool_checked_out_gauge: Gauge = Gauge( 

295 "couchers_db_pool_checked_out", 

296 "Checked-out DB connections, per worker process", 

297 multiprocess_mode="liveall", 

298) 

299 

300 

301def start_worker_resource_sampler(executor: ThreadPoolExecutor, engine: Engine, interval: float = 1.0) -> None: 

302 def sample() -> None: 

303 while True: 

304 # _work_queue is private but stable: tasks gRPC has submitted that no thread has picked up yet 

305 grpc_threadpool_queue_depth_gauge.set(executor._work_queue.qsize()) 

306 db_pool_checked_out_gauge.set(cast(QueuePool, engine.pool).checkedout()) 

307 time.sleep(interval) 

308 

309 threading.Thread(target=sample, daemon=True, name="resource-sampler").start() 

310 

311 

312supervised_children_alive_gauge: Gauge = Gauge( 

313 "couchers_supervised_children_alive", 

314 "Child processes (API workers, background workers, scheduler) the supervisor currently sees alive", 

315 multiprocess_mode="mostrecent", 

316) 

317 

318 

319# Simple count of API calls, broken down by method and the client platform header. Cheap (a counter, no buckets) and 

320# answers "how much traffic comes from each platform". 

321api_calls_counter: Counter = Counter( 

322 "couchers_api_calls_total", 

323 "Number of gRPC API calls", 

324 labelnames=["method", "platform"], 

325) 

326 

327 

328def observe_api_call(method: str, client_platform: ClientPlatform | None) -> None: 

329 api_calls_counter.labels(method, client_platform.name if client_platform is not None else "unknown").inc() 

330 

331 

332# list of gauge names and function to execute to set value to 

333# the python prometheus client does not support Gauge.set_function, so instead we hack around it and set each gauge just 

334# before collection with this 

335_set_hacky_gauges_funcs: list[tuple[Gauge, Callable[[], Any]]] = [] 

336 

337 

338def _make_gauge_from_query(name: str, description: str, statement: Select[Any]) -> Gauge: 

339 """ 

340 Given a name, description and statement that is a sqlalchemy statement, creates a gauge from it 

341 

342 statement should be a sqlalchemy SELECT statement that returns a single number 

343 """ 

344 

345 def f() -> Any: 

346 with tracer.start_as_current_span(f"metric.{name}"): 

347 with session_scope() as session: 

348 return session.execute(statement).scalar_one() 

349 

350 gauge = Gauge(name, description, multiprocess_mode="mostrecent") 

351 _set_hacky_gauges_funcs.append((gauge, f)) 

352 return gauge 

353 

354 

355# list of labeled gauges and the function to populate their label values just before collection 

356_set_hacky_labeled_gauges_funcs: list[tuple[Gauge, Callable[[Gauge], None]]] = [] 

357 

358 

359def _make_labeled_gauge_from_query( 

360 name: str, 

361 description: str, 

362 labelname: str, 

363 statement: Select[Any], 

364) -> Gauge: 

365 """ 

366 Given a name, description, label name and statement, creates a gauge with one label set from the statement. 

367 

368 statement should be a sqlalchemy SELECT statement that returns rows of (label_value, count). 

369 """ 

370 

371 gauge = Gauge(name, description, labelnames=[labelname], multiprocess_mode="mostrecent") 

372 

373 def f(g: Gauge) -> None: 

374 with tracer.start_as_current_span(f"metric.{name}"): 

375 with session_scope() as session: 

376 rows = session.execute(statement).all() 

377 for label_value, count in rows: 

378 g.labels(str(label_value)).set(count) 

379 

380 _set_hacky_labeled_gauges_funcs.append((gauge, f)) 

381 return gauge 

382 

383 

384# list of functions that each run one query and populate several gauges from the single result row 

385_set_hacky_multi_gauges_funcs: list[Callable[[], None]] = [] 

386 

387 

388def _make_gauges_from_single_pass( 

389 span_name: str, 

390 make_statement: Callable[[list[Any]], Select[Any]], 

391 specs: Sequence[tuple[str, str, ColumnElement[bool] | None]], 

392 labeled_specs: Sequence[tuple[Gauge, str, ColumnElement[bool]]] = (), 

393) -> list[Gauge]: 

394 """ 

395 Creates a gauge per spec, plus label values on already-created labeled gauges, all from one pass over the table. 

396 

397 Each spec is (name, description, condition) and counts the rows matching that condition, or every row if it is 

398 None; each labeled spec is (gauge, label value, condition). make_statement is handed the count columns and 

399 returns the statement to run them in, so the caller owns the FROM and any joins. 

400 

401 Counting with count(*) FILTER in a single statement rather than one statement per gauge is the whole point: as 

402 separate queries these were a full sequential scan of the table each, on every scrape. 

403 """ 

404 conditions = [condition for _, _, condition in specs] + [condition for _, _, condition in labeled_specs] 

405 keys = [f"c{i}" for i in range(len(conditions))] 

406 statement = make_statement( 

407 [ 

408 (func.count() if condition is None else func.count().filter(condition)).label(key) 

409 for key, condition in zip(keys, conditions) 

410 ] 

411 ) 

412 gauges = [Gauge(name, description, multiprocess_mode="mostrecent") for name, description, _ in specs] 

413 

414 def f() -> None: 

415 with tracer.start_as_current_span(f"metric.{span_name}"): 

416 with session_scope() as session: 

417 row = session.execute(statement).one()._mapping 

418 for gauge, key in zip(gauges, keys): 

419 gauge.set(row[key]) 

420 for (labeled_gauge, label_value, _), labeled_key in zip(labeled_specs, keys[len(specs) :]): 

421 labeled_gauge.labels(label_value).set(row[labeled_key]) 

422 

423 _set_hacky_multi_gauges_funcs.append(f) 

424 return gauges 

425 

426 

427_active_user_periods: list[tuple[str, str, timedelta]] = [ 

428 ("5m", "5 min", timedelta(minutes=5)), 

429 ("24h", "24 hours", timedelta(hours=24)), 

430 ("1month", "1 month", timedelta(weeks=4)), 

431 ("3month", "3 months", timedelta(weeks=13)), 

432 ("6month", "6 months", timedelta(weeks=26)), 

433 ("12month", "12 months", timedelta(days=365)), 

434] 

435 

436# Number of users bucketed by how recently they were last active. Ordered upper bounds, made mutually exclusive by 

437# _active_users_bucket_specs so every bucket can be counted in the same pass. 

438_active_users_buckets: list[tuple[str, timedelta | None]] = [ 

439 ("<1d", timedelta(days=1)), 

440 ("1d-1w", timedelta(days=7)), 

441 ("1w-1m", timedelta(weeks=4)), 

442 ("1m-6m", timedelta(weeks=26)), 

443 ("6m-12m", timedelta(days=365)), 

444 ("12m-24m", timedelta(days=730)), 

445 ("24m+", None), 

446] 

447_active_users_age = func.now() - User.last_active 

448 

449active_users_by_recency_gauge: Gauge = Gauge( 

450 "couchers_active_users_by_recency", 

451 "Number of users bucketed by how recently they were last active", 

452 labelnames=["period"], 

453 multiprocess_mode="mostrecent", 

454) 

455 

456 

457def _active_users_bucket_specs() -> list[tuple[Gauge, str, ColumnElement[bool]]]: 

458 specs = [] 

459 lower: timedelta | None = None 

460 for label, upper in _active_users_buckets: 

461 bounds = [] 

462 if lower is not None: 

463 bounds.append(_active_users_age >= lower) 

464 if upper is not None: 

465 bounds.append(_active_users_age < upper) 

466 specs.append((active_users_by_recency_gauge, label, and_(*bounds))) 

467 lower = upper 

468 return specs 

469 

470 

471def _make_users_gauges() -> list[Gauge]: 

472 # Galleries holding at least one photo, outer joined below so the avatar check in the completed profile spec is a 

473 # hash join rather than a subplan run once per user inside the FILTER clause. 

474 galleries_with_photos = select(PhotoGalleryItem.gallery_id).distinct().subquery("galleries_with_photos") 

475 

476 specs: list[tuple[str, str, ColumnElement[bool] | None]] = [ 

477 ("couchers_users", "Total number of users", None), 

478 *[ 

479 ( 

480 f"couchers_active_users_{name}", 

481 f"Number of active users in the last {description}", 

482 _active_users_age < interval, 

483 ) 

484 for name, description, interval in _active_user_periods 

485 ], 

486 ("couchers_users_man", "Total number of users with gender 'Man'", User.gender == "Man"), 

487 ("couchers_users_woman", "Total number of users with gender 'Woman'", User.gender == "Woman"), 

488 ("couchers_users_nonbinary", "Total number of users with gender 'Non-binary'", User.gender == "Non-binary"), 

489 ( 

490 "couchers_users_can_host", 

491 "Total number of users with hosting status 'can_host'", 

492 User.hosting_status == HostingStatus.can_host, 

493 ), 

494 ( 

495 "couchers_users_cant_host", 

496 "Total number of users with hosting status 'cant_host'", 

497 User.hosting_status == HostingStatus.cant_host, 

498 ), 

499 ( 

500 "couchers_users_maybe", 

501 "Total number of users with hosting status 'maybe'", 

502 User.hosting_status == HostingStatus.maybe, 

503 ), 

504 ( 

505 "couchers_users_completed_profile", 

506 "Total number of users with a completed profile", 

507 has_completed_profile_expression(galleries_with_photos), 

508 ), 

509 ( 

510 "couchers_users_completed_my_home", 

511 "Total number of users with a completed my home section", 

512 cast(ColumnElement[bool], User.has_completed_my_home), 

513 ), 

514 ] 

515 

516 return _make_gauges_from_single_pass( 

517 "couchers_users", 

518 lambda columns: ( 

519 select(*columns) 

520 .select_from(User) 

521 .outerjoin(galleries_with_photos, galleries_with_photos.c.gallery_id == User.profile_gallery_id) 

522 .where(User.is_visible) 

523 ), 

524 specs, 

525 _active_users_bucket_specs(), 

526 ) 

527 

528 

529# Each of these was its own SELECT count(*) FROM users, which added up to roughly sixteen full scans of the table on 

530# every scrape and dominated all sequential tuple reads in the database. 

531users_gauges: list[Gauge] = _make_users_gauges() 

532 

533# Number of users per community, labeled by community name. Only includes communities at the region level or 

534# broader (world, macroregion, region). 

535users_per_community_gauge: Gauge = _make_labeled_gauge_from_query( 

536 "couchers_users_per_community", 

537 "Number of users per community, for regions and broader", 

538 "community", 

539 ( 

540 select(Cluster.name, func.coalesce(ClusterSubscriptionCount.count, 0)) 

541 .select_from(Node) 

542 .join(Cluster, and_(Cluster.parent_node_id == Node.id, Cluster.is_official_cluster)) 

543 .outerjoin(ClusterSubscriptionCount, ClusterSubscriptionCount.cluster_id == Cluster.id) 

544 .where(Node.node_type <= NodeType.region) 

545 ), 

546) 

547 

548# Window for the per-platform daily-active-user metrics. Kept to 24h so the user_activity scan stays cheap (an index 

549# scan of just the last day's rows), letting these gauges be computed inline on every scrape. 

550_ACTIVE_USERS_BY_PLATFORM_WINDOW = timedelta(hours=24) 

551# Platforms counted as "mobile" for the mobile-share fraction (native apps plus the mobile web viewport). 

552_MOBILE_PLATFORMS = [ClientPlatform.web_mobile, ClientPlatform.app_ios, ClientPlatform.app_android] 

553 

554 

555def active_users_by_platform_statement() -> Select[Any]: 

556 # one scan of the last 24h of user_activity: distinct active users in total, the mobile subset (for the share 

557 # fraction), and a breakdown per platform. client_platform is set from a header the client explicitly sends; it's 

558 # null for some other client (e.g. an API key script) or activity from before the header existed, so the 

559 # per-platform counts don't sum to the total and "mobile" needs its own union count rather than summing labels. 

560 distinct_users = func.count(distinct(UserActivity.user_id)) 

561 return ( 

562 select( 

563 distinct_users.label("total"), 

564 distinct_users.filter(UserActivity.client_platform.in_(_MOBILE_PLATFORMS)).label("mobile"), 

565 *[ 

566 distinct_users.filter(UserActivity.client_platform == platform).label(platform.name) 

567 for platform in ClientPlatform 

568 ], 

569 ) 

570 .select_from(UserActivity) 

571 .join(User, User.id == UserActivity.user_id) 

572 .where(User.is_visible) 

573 .where(UserActivity.period > func.now() - _ACTIVE_USERS_BY_PLATFORM_WINDOW) 

574 ) 

575 

576 

577# Distinct active users in the last 24h, split by client platform. 

578active_users_by_platform_gauge: Gauge = Gauge( 

579 "couchers_active_users_by_platform", 

580 "Distinct active users in the last 24h, split by client platform (web_desktop, web_mobile, app_ios, app_android)", 

581 labelnames=["platform"], 

582 multiprocess_mode="mostrecent", 

583) 

584 

585# Fraction of the last 24h's distinct active users who had any mobile activity. The headline "mobile is key" number. 

586active_users_mobile_fraction_gauge: Gauge = Gauge( 

587 "couchers_active_users_mobile_fraction", 

588 "Fraction of distinct active users in the last 24h with any mobile activity (web_mobile, app_ios, app_android)", 

589 multiprocess_mode="mostrecent", 

590) 

591 

592 

593def _set_active_users_by_platform(gauge: Gauge) -> None: 

594 with tracer.start_as_current_span("metric.couchers_active_users_by_platform"): 

595 with session_scope() as session: 

596 row = session.execute(active_users_by_platform_statement()).one()._mapping 

597 for platform in ClientPlatform: 

598 gauge.labels(platform.name).set(row[platform.name]) 

599 total = row["total"] 

600 active_users_mobile_fraction_gauge.set(row["mobile"] / total if total else 0.0) 

601 

602 

603_set_hacky_labeled_gauges_funcs.append((active_users_by_platform_gauge, _set_active_users_by_platform)) 

604 

605sent_message_gauge: Gauge = _make_gauge_from_query( 

606 "couchers_users_sent_message", 

607 "Total number of users who have sent a message", 

608 (select(func.count(distinct(Message.author_id))).join(User, User.id == Message.author_id).where(User.is_visible)), 

609) 

610 

611sent_request_gauge: Gauge = _make_gauge_from_query( 

612 "couchers_users_sent_request", 

613 "Total number of users who have sent a host request", 

614 ( 

615 select(func.count(distinct(HostRequest.initiator_user_id))) 

616 .join(User, User.id == HostRequest.initiator_user_id) 

617 .where(User.is_visible) 

618 ), 

619) 

620 

621has_reference_gauge: Gauge = _make_gauge_from_query( 

622 "couchers_users_has_reference", 

623 "Total number of users who have a reference", 

624 ( 

625 select(func.count(distinct(Reference.to_user_id))) 

626 .join(User, User.id == Reference.to_user_id) 

627 .where(User.is_visible) 

628 ), 

629) 

630 

631rsvpd_to_event_gauge: Gauge = _make_gauge_from_query( 

632 "couchers_users_rsvpd_to_event", 

633 "Total number of users who have RSVPd to an event", 

634 ( 

635 select(func.count(distinct(EventOccurrenceAttendee.user_id))) 

636 .join(User, User.id == EventOccurrenceAttendee.user_id) 

637 .where(User.is_visible) 

638 ), 

639) 

640 

641background_jobs_ready_to_execute_gauge: Gauge = _make_gauge_from_query( 

642 "couchers_background_jobs_ready_to_execute", 

643 "Total number of background jobs ready to execute", 

644 select(func.count()).select_from(BackgroundJob).where(BackgroundJob.ready_for_retry), 

645) 

646 

647background_jobs_no_jobs_counter: Counter = Counter( 

648 "couchers_background_jobs_no_jobs_total", 

649 "Number of times a bg worker tries to grab a job but there is none", 

650) 

651 

652background_jobs_got_job_counter: Counter = Counter( 

653 "couchers_background_jobs_got_job_total", 

654 "Number of times a bg worker grabbed a job", 

655) 

656 

657 

658signup_initiations_counter: Counter = Counter( 

659 "couchers_signup_initiations_total", 

660 "Number of initiated signups", 

661) 

662signup_completions_counter: Counter = Counter( 

663 "couchers_signup_completions_total", 

664 "Number of completed signups", 

665 labelnames=["gender"], 

666) 

667# Per-step signup funnel counters. Each fires once, the first time a signup flow satisfies the given gate, so 

668# that step_total/initiations_total gives the fraction of signups that reached that step. Unlabeled to match 

669# signup_initiations_counter for clean ratios. 

670signup_account_filled_counter: Counter = Counter( 

671 "couchers_signup_account_filled_total", 

672 "Number of signup flows that filled in their account details", 

673) 

674signup_email_verified_counter: Counter = Counter( 

675 "couchers_signup_email_verified_total", 

676 "Number of signup flows that verified their email address", 

677) 

678signup_guidelines_accepted_counter: Counter = Counter( 

679 "couchers_signup_guidelines_accepted_total", 

680 "Number of signup flows that accepted the community guidelines", 

681) 

682signup_motivations_filled_counter: Counter = Counter( 

683 "couchers_signup_motivations_filled_total", 

684 "Number of signup flows that filled in their motivations", 

685) 

686signup_time_histogram: Histogram = Histogram( 

687 "couchers_signup_time_seconds", 

688 "Time taken for a user to sign up", 

689 labelnames=["gender"], 

690 buckets=(30, 60, 90, 120, 180, 240, 300, 360, 420, 480, 540, 600, 900, 1200, 1800, 3600, 7200, _INF), 

691) 

692 

693logins_counter: Counter = Counter( 

694 "couchers_logins_total", 

695 "Number of logins", 

696 labelnames=["gender"], 

697) 

698 

699password_reset_initiations_counter: Counter = Counter( 

700 "couchers_password_reset_initiations_total", 

701 "Number of password reset initiations", 

702) 

703password_reset_completions_counter: Counter = Counter( 

704 "couchers_password_reset_completions_total", 

705 "Number of password reset completions", 

706) 

707 

708account_deletion_initiations_counter: Counter = Counter( 

709 "couchers_account_deletion_initiations_total", 

710 "Number of account deletion initiations", 

711 labelnames=["gender"], 

712) 

713account_deletion_completions_counter: Counter = Counter( 

714 "couchers_account_deletion_completions_total", 

715 "Number of account deletion completions", 

716 labelnames=["gender"], 

717) 

718account_recoveries_counter: Counter = Counter( 

719 "couchers_account_recoveries_total", 

720 "Number of account recoveries", 

721 labelnames=["gender"], 

722) 

723 

724strong_verification_initiations_counter: Counter = Counter( 

725 "couchers_strong_verification_initiations_total", 

726 "Number of strong verification initiations", 

727 labelnames=["gender"], 

728) 

729strong_verification_completions_counter: Counter = Counter( 

730 "couchers_strong_verification_completions_total", 

731 "Number of strong verification completions", 

732) 

733strong_verification_data_deletions_counter: Counter = Counter( 

734 "couchers_strong_verification_data_deletions_total", 

735 "Number of strong verification data deletions", 

736 labelnames=["gender"], 

737) 

738 

739host_requests_sent_counter: Counter = Counter( 

740 "couchers_host_requests_total", 

741 "Number of host requests sent", 

742 labelnames=["from_gender", "to_gender"], 

743) 

744host_request_responses_counter: Counter = Counter( 

745 "couchers_host_requests_responses_total", 

746 "Number of responses to host requests", 

747 labelnames=["responder_gender", "other_gender", "response_type"], 

748) 

749 

750sent_messages_counter: Counter = Counter( 

751 "couchers_sent_messages_total", 

752 "Number of messages sent", 

753 labelnames=["gender", "message_type"], 

754) 

755 

756 

757push_notification_counter: Counter = Counter( 

758 "couchers_push_notification_total", 

759 "Number of push notification delivery attempts", 

760 labelnames=["platform", "outcome"], 

761) 

762emails_counter: Counter = Counter( 

763 "couchers_emails_total", 

764 "Number of emails sent", 

765) 

766 

767 

768# Revenue from successful Stripe charges, in cents, split by source (donation vs merch). 

769revenue_cents_counter: Counter = Counter( 

770 "couchers_revenue_cents_total", 

771 "Revenue from successful Stripe charges, in cents", 

772 labelnames=["type"], 

773) 

774 

775 

776def observe_revenue(revenue_type: str, amount_cents: int) -> None: 

777 revenue_cents_counter.labels(revenue_type).inc(amount_cents) 

778 

779 

780antibots_assessed_counter: Counter = Counter( 

781 "couchers_antibots_assessed_total", 

782 "Number of times an antibot assessment is created", 

783 labelnames=["action"], 

784) 

785 

786antibot_score_histogram: Histogram = Histogram( 

787 "couchers_antibot_score", 

788 "Score of antibot assessments", 

789 labelnames=["action"], 

790 buckets=tuple(x / 20 for x in range(0, 21)), 

791) 

792 

793host_request_first_response_histogram: Histogram = Histogram( 

794 "couchers_host_request_first_response_seconds", 

795 "Response time to host requests", 

796 labelnames=["host_gender", "surfer_gender", "response_type"], 

797 buckets=( 

798 1 * 60, # 1m 

799 2 * 60, # 2m 

800 5 * 60, # 5m 

801 10 * 60, # 10m 

802 15 * 60, # 15m 

803 30 * 60, # 30m 

804 45 * 60, # 45m 

805 3_600, # 1h 

806 2 * 3_600, # 2h 

807 3 * 3_600, # 3h 

808 6 * 3_600, # 6h 

809 12 * 3_600, # 12h 

810 86_400, # 24h 

811 2 * 86_400, # 2d 

812 5 * 86_400, # 4d 

813 602_000, # 1w 

814 2 * 602_000, # 2w 

815 3 * 602_000, # 3w 

816 4 * 602_000, # 4w 

817 _INF, 

818 ), 

819) 

820account_age_on_host_request_create_histogram: Histogram = Histogram( 

821 "couchers_account_age_on_host_request_create_histogram_seconds", 

822 "Age of account sending a host request", 

823 labelnames=["surfer_gender", "host_gender"], 

824 buckets=( 

825 5 * 60, # 5m 

826 10 * 60, # 10m 

827 15 * 60, # 15m 

828 30 * 60, # 30m 

829 45 * 60, # 45m 

830 3_600, # 1h 

831 2 * 3_600, # 2h 

832 3 * 3_600, # 3h 

833 6 * 3_600, # 6h 

834 12 * 3_600, # 12h 

835 86_400, # 24h 

836 2 * 86_400, # 2d 

837 3 * 86_400, # 3d 

838 4 * 86_400, # 4d 

839 5 * 86_400, # 5d 

840 6 * 86_400, # 6d 

841 602_000, # 1w 

842 2 * 602_000, # 2w 

843 3 * 602_000, # 3w 

844 4 * 602_000, # 4w 

845 5 * 602_000, # 5w 

846 10 * 602_000, # 10w 

847 25 * 602_000, # 25w 

848 52 * 602_000, # 52w 

849 104 * 602_000, # 104w 

850 _INF, 

851 ), 

852) 

853 

854 

855# ============================================================================= 

856# Moderation metrics 

857# ============================================================================= 

858 

859# Gauges: Queue lengths 

860moderation_queue_length_gauge: Gauge = _make_gauge_from_query( 

861 "couchers_moderation_queue_length", 

862 "Total number of unresolved items in the moderation queue", 

863 select(func.count()).select_from(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id.is_(None)), 

864) 

865 

866moderation_queue_length_by_trigger_gauges: list[Gauge] = [ 

867 _make_gauge_from_query( 

868 f"couchers_moderation_queue_length_{trigger.name.lower()}", 

869 f"Number of unresolved items in the moderation queue with trigger {trigger.name}", 

870 select(func.count()) 

871 .select_from(ModerationQueueItem) 

872 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

873 .where(ModerationQueueItem.trigger == trigger), 

874 ) 

875 for trigger in ModerationTrigger 

876] 

877 

878moderation_queue_length_by_object_type_gauges: list[Gauge] = [ 

879 _make_gauge_from_query( 

880 f"couchers_moderation_queue_length_{object_type.name.lower()}", 

881 f"Number of unresolved items in the moderation queue for {object_type.name}", 

882 select(func.count()) 

883 .select_from(ModerationQueueItem) 

884 .join(ModerationState, ModerationQueueItem.moderation_state_id == ModerationState.id) 

885 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

886 .where(ModerationState.object_type == object_type), 

887 ) 

888 for object_type in ModerationObjectType 

889] 

890 

891# Gauges: Items in each visibility state by object type 

892moderation_visibility_gauges: list[Gauge] = [ 

893 _make_gauge_from_query( 

894 f"couchers_moderation_items_{object_type.name.lower()}_{visibility.name.lower()}", 

895 f"Number of {object_type.name} items with visibility {visibility.name}", 

896 select(func.count()) 

897 .select_from(ModerationState) 

898 .where(ModerationState.object_type == object_type) 

899 .where(ModerationState.visibility == visibility), 

900 ) 

901 for object_type in ModerationObjectType 

902 for visibility in ModerationVisibility 

903] 

904 

905# Counters: Moderation actions taken 

906moderation_actions_counter: Counter = Counter( 

907 "couchers_moderation_actions_total", 

908 "Number of moderation actions taken", 

909 labelnames=["action", "object_type"], 

910) 

911 

912 

913def observe_moderation_action(action: ModerationAction, object_type: ModerationObjectType) -> None: 

914 moderation_actions_counter.labels(action.name, object_type.name).inc() 

915 

916 

917# Counters: Visibility state transitions 

918moderation_visibility_transitions_counter: Counter = Counter( 

919 "couchers_moderation_visibility_transitions_total", 

920 "Number of visibility state transitions", 

921 labelnames=["from_visibility", "to_visibility", "object_type"], 

922) 

923 

924 

925def observe_moderation_visibility_transition( 

926 from_visibility: ModerationVisibility, to_visibility: ModerationVisibility, object_type: ModerationObjectType 

927) -> None: 

928 moderation_visibility_transitions_counter.labels(from_visibility.name, to_visibility.name, object_type.name).inc() 

929 

930 

931# Counters: Auto-approved items 

932moderation_auto_approved_counter: Counter = Counter( 

933 "couchers_moderation_auto_approved_total", 

934 "Number of items that were auto-approved", 

935) 

936 

937 

938# Counters: Queue items created 

939moderation_queue_items_created_counter: Counter = Counter( 

940 "couchers_moderation_queue_items_created_total", 

941 "Number of moderation queue items created", 

942 labelnames=["trigger", "object_type"], 

943) 

944 

945 

946def observe_moderation_queue_item_created(trigger: ModerationTrigger, object_type: ModerationObjectType) -> None: 

947 moderation_queue_items_created_counter.labels(trigger.name, object_type.name).inc() 

948 

949 

950# Counters: Queue items resolved 

951moderation_queue_items_resolved_counter: Counter = Counter( 

952 "couchers_moderation_queue_items_resolved_total", 

953 "Number of moderation queue items resolved", 

954 labelnames=["trigger", "action", "object_type"], 

955) 

956 

957 

958def observe_moderation_queue_item_resolved( 

959 trigger: ModerationTrigger, action: ModerationAction, object_type: ModerationObjectType 

960) -> None: 

961 moderation_queue_items_resolved_counter.labels(trigger.name, action.name, object_type.name).inc() 

962 

963 

964# Histogram: Time to resolve queue items 

965moderation_queue_resolution_time_histogram: Histogram = Histogram( 

966 "couchers_moderation_queue_resolution_seconds", 

967 "Time taken to resolve moderation queue items", 

968 labelnames=["trigger", "action", "object_type"], 

969 buckets=( 

970 0.1, 

971 0.25, 

972 0.5, 

973 1, 

974 2.5, 

975 5, 

976 10, 

977 30, 

978 60, 

979 5 * 60, 

980 15 * 60, 

981 30 * 60, 

982 3_600, 

983 2 * 3_600, 

984 6 * 3_600, 

985 12 * 3_600, 

986 86_400, 

987 2 * 86_400, 

988 3 * 86_400, 

989 7 * 86_400, 

990 14 * 86_400, 

991 30 * 86_400, 

992 _INF, 

993 ), 

994) 

995 

996 

997def observe_moderation_queue_resolution_time( 

998 trigger: ModerationTrigger, action: ModerationAction, object_type: ModerationObjectType, duration_s: float 

999) -> None: 

1000 moderation_queue_resolution_time_histogram.labels(trigger.name, action.name, object_type.name).observe(duration_s) 

1001 

1002 

1003nonvisible_user_access_counter: Counter = Counter( 

1004 "couchers_nonvisible_user_access_total", 

1005 "Number of access events involving nonvisible (banned/shadowed/deleted) users", 

1006 labelnames=["access_type", "target_state"], 

1007) 

1008 

1009 

1010def observe_nonvisible_user_access(access_type: NonvisibleUserAccessType, target_state: NonvisibleUserState) -> None: 

1011 nonvisible_user_access_counter.labels(access_type.name, target_state.name).inc() 

1012 

1013 

1014postcards_sent_counter: Counter = Counter( 

1015 "couchers_postcards_sent_total", 

1016 "Number of postcards sent via MyPostcard", 

1017 labelnames=["country_code"], 

1018) 

1019 

1020 

1021# Native app / OTA update metrics. Bucket layout is minute-resolution at the low end (watch an OTA 

1022# rolling out), dense around the OTA (~28d) and store (~91d) windows, and sparse past it for stragglers. 

1023_NATIVE_AGE_BUCKETS: tuple[float, ...] = ( 

1024 60, 

1025 5 * 60, 

1026 15 * 60, 

1027 30 * 60, 

1028 3_600, 

1029 2 * 3_600, 

1030 6 * 3_600, 

1031 12 * 3_600, 

1032 86_400, 

1033 2 * 86_400, 

1034 3 * 86_400, 

1035 5 * 86_400, 

1036 7 * 86_400, 

1037 10 * 86_400, 

1038 14 * 86_400, 

1039 21 * 86_400, 

1040 28 * 86_400, 

1041 35 * 86_400, 

1042 45 * 86_400, 

1043 60 * 86_400, 

1044 75 * 86_400, 

1045 91 * 86_400, 

1046 120 * 86_400, 

1047 150 * 86_400, 

1048 180 * 86_400, 

1049 270 * 86_400, 

1050 365 * 86_400, 

1051 730 * 86_400, 

1052 _INF, 

1053) 

1054 

1055native_bundle_age_histogram: Histogram = Histogram( 

1056 "couchers_native_bundle_age_seconds", 

1057 "Age of the OTA bundle reported by the client at CheckNativeStatus, by platform and launch source", 

1058 labelnames=["platform", "is_ota_launch"], 

1059 buckets=_NATIVE_AGE_BUCKETS, 

1060) 

1061 

1062 

1063def observe_native_bundle_age(platform: str, is_ota_launch: bool, age_s: float) -> None: 

1064 native_bundle_age_histogram.labels(platform or "unknown", "true" if is_ota_launch else "false").observe(age_s) 

1065 

1066 

1067native_binary_age_histogram: Histogram = Histogram( 

1068 "couchers_native_binary_age_seconds", 

1069 "Age of the embedded native binary reported by the client at CheckNativeStatus, by platform", 

1070 labelnames=["platform"], 

1071 buckets=_NATIVE_AGE_BUCKETS, 

1072) 

1073 

1074 

1075def observe_native_binary_age(platform: str, age_s: float) -> None: 

1076 native_binary_age_histogram.labels(platform or "unknown").observe(age_s) 

1077 

1078 

1079native_update_decisions_counter: Counter = Counter( 

1080 "couchers_native_update_decisions_total", 

1081 "CheckNativeStatus decisions, by platform / action / severity", 

1082 labelnames=["platform", "action", "severity"], 

1083) 

1084 

1085 

1086def observe_native_update_decision(platform: str, action: str, severity: str) -> None: 

1087 native_update_decisions_counter.labels(platform or "unknown", action, severity).inc() 

1088 

1089 

1090native_banned_bundle_hits_counter: Counter = Counter( 

1091 "couchers_native_banned_bundle_hits_total", 

1092 "CheckNativeStatus calls from a device running a banned OTA bundle, by platform", 

1093 labelnames=["platform"], 

1094) 

1095 

1096 

1097def observe_native_banned_bundle_hit(platform: str) -> None: 

1098 native_banned_bundle_hits_counter.labels(platform or "unknown").inc() 

1099 

1100 

1101native_ota_manifest_requests_counter: Counter = Counter( 

1102 "couchers_native_ota_manifest_requests_total", 

1103 "GetNativeUpdateManifest requests, by platform and result (served, no_update, no_match)", 

1104 labelnames=["platform", "result"], 

1105) 

1106 

1107 

1108def observe_native_ota_manifest_request(platform: str, result: str) -> None: 

1109 native_ota_manifest_requests_counter.labels(platform or "unknown", result).inc() 

1110 

1111 

1112# One increment per CheckNativeStatus, labeled by build/bundle identity, to see the live mix of 

1113# versions and bundles running in the fleet. 

1114native_client_checkins_counter: Counter = Counter( 

1115 "couchers_native_client_checkins_total", 

1116 "CheckNativeStatus calls, labeled by build/bundle identity", 

1117 labelnames=[ 

1118 "platform", 

1119 "is_ota_launch", 

1120 "embedded_display_version", 

1121 "embedded_runtime_version", 

1122 "ota_display_version", 

1123 "ota_update_id", 

1124 ], 

1125) 

1126 

1127 

1128def observe_native_client_checkin( 

1129 platform: str, 

1130 is_ota_launch: bool, 

1131 embedded_display_version: str, 

1132 embedded_runtime_version: str, 

1133 ota_display_version: str, 

1134 ota_update_id: str, 

1135) -> None: 

1136 native_client_checkins_counter.labels( 

1137 platform or "unknown", 

1138 "true" if is_ota_launch else "false", 

1139 embedded_display_version or "unknown", 

1140 embedded_runtime_version or "unknown", 

1141 ota_display_version or "none", 

1142 ota_update_id or "none", 

1143 ).inc() 

1144 

1145 

1146# Recomputed at scrape time via the hacky-gauge mechanism, so it reflects live age. 0 when disabled 

1147# or never pulled. 

1148def _feature_flags_staleness_seconds() -> float: 

1149 return experimentation.seconds_since_last_fetch() or 0.0 

1150 

1151 

1152feature_flags_staleness_gauge: Gauge = Gauge( 

1153 "couchers_feature_flags_staleness_seconds", 

1154 "Seconds since feature flags were last successfully fetched from GrowthBook", 

1155 multiprocess_mode="mostrecent", 

1156) 

1157_set_hacky_gauges_funcs.append((feature_flags_staleness_gauge, _feature_flags_staleness_seconds)) 

1158 

1159 

1160feature_flag_evaluations_counter: Counter = Counter( 

1161 "couchers_feature_flag_evaluations_total", 

1162 "Number of feature flag evaluations, by flag key, evaluation source, and resolved value", 

1163 labelnames=["flag_key", "source", "value"], 

1164) 

1165 

1166_MAX_FLAG_VALUE_LABEL_LEN = 32 

1167 

1168 

1169def _stringify_flag_value(value: Any) -> str: 

1170 if isinstance(value, bool): 

1171 return "true" if value else "false" 

1172 if isinstance(value, (int, float, str)): 

1173 s = str(value) 

1174 return s if len(s) <= _MAX_FLAG_VALUE_LABEL_LEN else f"<{type(value).__name__}>" 

1175 if value is None: 1175 ↛ 1177line 1175 didn't jump to line 1177 because the condition on line 1175 was always true

1176 return "None" 

1177 return f"<{type(value).__name__}>" 

1178 

1179 

1180def observe_feature_flag_evaluation(flag_key: str, source: str, value: Any) -> None: 

1181 feature_flag_evaluations_counter.labels(flag_key, source, _stringify_flag_value(value)).inc() 

1182 

1183 

1184def create_prometheus_server(port: int) -> Any: 

1185 """custom start method to fix problem descrbied in https://github.com/prometheus/client_python/issues/155""" 

1186 

1187 def app(environ: Any, start_response: Any) -> Any: 

1188 # set hacky gauges 

1189 for gauge, f in _set_hacky_gauges_funcs: 

1190 gauge.set(f()) 

1191 for gauge, labeled_f in _set_hacky_labeled_gauges_funcs: 

1192 labeled_f(gauge) 

1193 for multi_f in _set_hacky_multi_gauges_funcs: 

1194 multi_f() 

1195 

1196 data = generate_latest(registry) 

1197 start_response("200 OK", [("Content-type", CONTENT_TYPE_LATEST), ("Content-Length", str(len(data)))]) 

1198 return [data] 

1199 

1200 httpd = exposition.make_server( # type: ignore[attr-defined] 

1201 "", port, app, exposition.ThreadingWSGIServer, handler_class=exposition._SilentHandler 

1202 ) 

1203 t = threading.Thread(target=httpd.serve_forever) 

1204 t.daemon = True 

1205 t.start() 

1206 return httpd