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

279 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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.middleware.perf import PerfResult 

32from couchers.models import ( 

33 BackgroundJob, 

34 ClientPlatform, 

35 Cluster, 

36 EventOccurrenceAttendee, 

37 HostingStatus, 

38 HostRequest, 

39 Message, 

40 Node, 

41 NodeType, 

42 NonvisibleUserAccessType, 

43 NonvisibleUserState, 

44 Reference, 

45 User, 

46 UserActivity, 

47) 

48from couchers.models.moderation import ( 

49 ModerationAction, 

50 ModerationObjectType, 

51 ModerationQueueItem, 

52 ModerationState, 

53 ModerationTrigger, 

54 ModerationVisibility, 

55) 

56from couchers.models.uploads import PhotoGalleryItem 

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/middleware/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 

693signup_email_changes_counter: Counter = Counter( 

694 "couchers_signup_email_changes_total", 

695 "Number of times email is changed during signup", 

696) 

697 

698logins_counter: Counter = Counter( 

699 "couchers_logins_total", 

700 "Number of logins", 

701 labelnames=["gender"], 

702) 

703 

704password_reset_initiations_counter: Counter = Counter( 

705 "couchers_password_reset_initiations_total", 

706 "Number of password reset initiations", 

707) 

708password_reset_completions_counter: Counter = Counter( 

709 "couchers_password_reset_completions_total", 

710 "Number of password reset completions", 

711) 

712 

713account_deletion_initiations_counter: Counter = Counter( 

714 "couchers_account_deletion_initiations_total", 

715 "Number of account deletion initiations", 

716 labelnames=["gender"], 

717) 

718account_deletion_completions_counter: Counter = Counter( 

719 "couchers_account_deletion_completions_total", 

720 "Number of account deletion completions", 

721 labelnames=["gender"], 

722) 

723account_recoveries_counter: Counter = Counter( 

724 "couchers_account_recoveries_total", 

725 "Number of account recoveries", 

726 labelnames=["gender"], 

727) 

728 

729strong_verification_initiations_counter: Counter = Counter( 

730 "couchers_strong_verification_initiations_total", 

731 "Number of strong verification initiations", 

732 labelnames=["gender"], 

733) 

734strong_verification_completions_counter: Counter = Counter( 

735 "couchers_strong_verification_completions_total", 

736 "Number of strong verification completions", 

737) 

738strong_verification_data_deletions_counter: Counter = Counter( 

739 "couchers_strong_verification_data_deletions_total", 

740 "Number of strong verification data deletions", 

741 labelnames=["gender"], 

742) 

743 

744host_requests_sent_counter: Counter = Counter( 

745 "couchers_host_requests_total", 

746 "Number of host requests sent", 

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

748) 

749host_request_responses_counter: Counter = Counter( 

750 "couchers_host_requests_responses_total", 

751 "Number of responses to host requests", 

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

753) 

754 

755sent_messages_counter: Counter = Counter( 

756 "couchers_sent_messages_total", 

757 "Number of messages sent", 

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

759) 

760 

761 

762push_notification_counter: Counter = Counter( 

763 "couchers_push_notification_total", 

764 "Number of push notification delivery attempts", 

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

766) 

767emails_counter: Counter = Counter( 

768 "couchers_emails_total", 

769 "Number of emails sent", 

770) 

771 

772 

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

774revenue_cents_counter: Counter = Counter( 

775 "couchers_revenue_cents_total", 

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

777 labelnames=["type"], 

778) 

779 

780 

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

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

783 

784 

785antibots_assessed_counter: Counter = Counter( 

786 "couchers_antibots_assessed_total", 

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

788 labelnames=["action"], 

789) 

790 

791antibot_score_histogram: Histogram = Histogram( 

792 "couchers_antibot_score", 

793 "Score of antibot assessments", 

794 labelnames=["action"], 

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

796) 

797 

798host_request_first_response_histogram: Histogram = Histogram( 

799 "couchers_host_request_first_response_seconds", 

800 "Response time to host requests", 

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

802 buckets=( 

803 1 * 60, # 1m 

804 2 * 60, # 2m 

805 5 * 60, # 5m 

806 10 * 60, # 10m 

807 15 * 60, # 15m 

808 30 * 60, # 30m 

809 45 * 60, # 45m 

810 3_600, # 1h 

811 2 * 3_600, # 2h 

812 3 * 3_600, # 3h 

813 6 * 3_600, # 6h 

814 12 * 3_600, # 12h 

815 86_400, # 24h 

816 2 * 86_400, # 2d 

817 5 * 86_400, # 4d 

818 602_000, # 1w 

819 2 * 602_000, # 2w 

820 3 * 602_000, # 3w 

821 4 * 602_000, # 4w 

822 _INF, 

823 ), 

824) 

825account_age_on_host_request_create_histogram: Histogram = Histogram( 

826 "couchers_account_age_on_host_request_create_histogram_seconds", 

827 "Age of account sending a host request", 

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

829 buckets=( 

830 5 * 60, # 5m 

831 10 * 60, # 10m 

832 15 * 60, # 15m 

833 30 * 60, # 30m 

834 45 * 60, # 45m 

835 3_600, # 1h 

836 2 * 3_600, # 2h 

837 3 * 3_600, # 3h 

838 6 * 3_600, # 6h 

839 12 * 3_600, # 12h 

840 86_400, # 24h 

841 2 * 86_400, # 2d 

842 3 * 86_400, # 3d 

843 4 * 86_400, # 4d 

844 5 * 86_400, # 5d 

845 6 * 86_400, # 6d 

846 602_000, # 1w 

847 2 * 602_000, # 2w 

848 3 * 602_000, # 3w 

849 4 * 602_000, # 4w 

850 5 * 602_000, # 5w 

851 10 * 602_000, # 10w 

852 25 * 602_000, # 25w 

853 52 * 602_000, # 52w 

854 104 * 602_000, # 104w 

855 _INF, 

856 ), 

857) 

858 

859 

860# ============================================================================= 

861# Moderation metrics 

862# ============================================================================= 

863 

864# Gauges: Queue lengths 

865moderation_queue_length_gauge: Gauge = _make_gauge_from_query( 

866 "couchers_moderation_queue_length", 

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

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

869) 

870 

871moderation_queue_length_by_trigger_gauges: list[Gauge] = [ 

872 _make_gauge_from_query( 

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

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

875 select(func.count()) 

876 .select_from(ModerationQueueItem) 

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

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

879 ) 

880 for trigger in ModerationTrigger 

881] 

882 

883moderation_queue_length_by_object_type_gauges: list[Gauge] = [ 

884 _make_gauge_from_query( 

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

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

887 select(func.count()) 

888 .select_from(ModerationQueueItem) 

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

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

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

892 ) 

893 for object_type in ModerationObjectType 

894] 

895 

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

897moderation_visibility_gauges: list[Gauge] = [ 

898 _make_gauge_from_query( 

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

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

901 select(func.count()) 

902 .select_from(ModerationState) 

903 .where(ModerationState.object_type == object_type) 

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

905 ) 

906 for object_type in ModerationObjectType 

907 for visibility in ModerationVisibility 

908] 

909 

910# Counters: Moderation actions taken 

911moderation_actions_counter: Counter = Counter( 

912 "couchers_moderation_actions_total", 

913 "Number of moderation actions taken", 

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

915) 

916 

917 

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

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

920 

921 

922# Counters: Visibility state transitions 

923moderation_visibility_transitions_counter: Counter = Counter( 

924 "couchers_moderation_visibility_transitions_total", 

925 "Number of visibility state transitions", 

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

927) 

928 

929 

930def observe_moderation_visibility_transition( 

931 from_visibility: ModerationVisibility, to_visibility: ModerationVisibility, object_type: ModerationObjectType 

932) -> None: 

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

934 

935 

936# Counters: Auto-approved items 

937moderation_auto_approved_counter: Counter = Counter( 

938 "couchers_moderation_auto_approved_total", 

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

940) 

941 

942 

943# Counters: Queue items created 

944moderation_queue_items_created_counter: Counter = Counter( 

945 "couchers_moderation_queue_items_created_total", 

946 "Number of moderation queue items created", 

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

948) 

949 

950 

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

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

953 

954 

955# Counters: Queue items resolved 

956moderation_queue_items_resolved_counter: Counter = Counter( 

957 "couchers_moderation_queue_items_resolved_total", 

958 "Number of moderation queue items resolved", 

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

960) 

961 

962 

963def observe_moderation_queue_item_resolved( 

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

965) -> None: 

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

967 

968 

969# Histogram: Time to resolve queue items 

970moderation_queue_resolution_time_histogram: Histogram = Histogram( 

971 "couchers_moderation_queue_resolution_seconds", 

972 "Time taken to resolve moderation queue items", 

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

974 buckets=( 

975 0.1, 

976 0.25, 

977 0.5, 

978 1, 

979 2.5, 

980 5, 

981 10, 

982 30, 

983 60, 

984 5 * 60, 

985 15 * 60, 

986 30 * 60, 

987 3_600, 

988 2 * 3_600, 

989 6 * 3_600, 

990 12 * 3_600, 

991 86_400, 

992 2 * 86_400, 

993 3 * 86_400, 

994 7 * 86_400, 

995 14 * 86_400, 

996 30 * 86_400, 

997 _INF, 

998 ), 

999) 

1000 

1001 

1002def observe_moderation_queue_resolution_time( 

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

1004) -> None: 

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

1006 

1007 

1008nonvisible_user_access_counter: Counter = Counter( 

1009 "couchers_nonvisible_user_access_total", 

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

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

1012) 

1013 

1014 

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

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

1017 

1018 

1019postcards_sent_counter: Counter = Counter( 

1020 "couchers_postcards_sent_total", 

1021 "Number of postcards sent via MyPostcard", 

1022 labelnames=["country_code"], 

1023) 

1024 

1025 

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

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

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

1029 60, 

1030 5 * 60, 

1031 15 * 60, 

1032 30 * 60, 

1033 3_600, 

1034 2 * 3_600, 

1035 6 * 3_600, 

1036 12 * 3_600, 

1037 86_400, 

1038 2 * 86_400, 

1039 3 * 86_400, 

1040 5 * 86_400, 

1041 7 * 86_400, 

1042 10 * 86_400, 

1043 14 * 86_400, 

1044 21 * 86_400, 

1045 28 * 86_400, 

1046 35 * 86_400, 

1047 45 * 86_400, 

1048 60 * 86_400, 

1049 75 * 86_400, 

1050 91 * 86_400, 

1051 120 * 86_400, 

1052 150 * 86_400, 

1053 180 * 86_400, 

1054 270 * 86_400, 

1055 365 * 86_400, 

1056 730 * 86_400, 

1057 _INF, 

1058) 

1059 

1060native_bundle_age_histogram: Histogram = Histogram( 

1061 "couchers_native_bundle_age_seconds", 

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

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

1064 buckets=_NATIVE_AGE_BUCKETS, 

1065) 

1066 

1067 

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

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

1070 

1071 

1072native_binary_age_histogram: Histogram = Histogram( 

1073 "couchers_native_binary_age_seconds", 

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

1075 labelnames=["platform"], 

1076 buckets=_NATIVE_AGE_BUCKETS, 

1077) 

1078 

1079 

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

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

1082 

1083 

1084native_update_decisions_counter: Counter = Counter( 

1085 "couchers_native_update_decisions_total", 

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

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

1088) 

1089 

1090 

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

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

1093 

1094 

1095native_banned_bundle_hits_counter: Counter = Counter( 

1096 "couchers_native_banned_bundle_hits_total", 

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

1098 labelnames=["platform"], 

1099) 

1100 

1101 

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

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

1104 

1105 

1106native_ota_manifest_requests_counter: Counter = Counter( 

1107 "couchers_native_ota_manifest_requests_total", 

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

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

1110) 

1111 

1112 

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

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

1115 

1116 

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

1118# versions and bundles running in the fleet. 

1119native_client_checkins_counter: Counter = Counter( 

1120 "couchers_native_client_checkins_total", 

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

1122 labelnames=[ 

1123 "platform", 

1124 "is_ota_launch", 

1125 "embedded_display_version", 

1126 "embedded_runtime_version", 

1127 "ota_display_version", 

1128 "ota_update_id", 

1129 ], 

1130) 

1131 

1132 

1133def observe_native_client_checkin( 

1134 platform: str, 

1135 is_ota_launch: bool, 

1136 embedded_display_version: str, 

1137 embedded_runtime_version: str, 

1138 ota_display_version: str, 

1139 ota_update_id: str, 

1140) -> None: 

1141 native_client_checkins_counter.labels( 

1142 platform or "unknown", 

1143 "true" if is_ota_launch else "false", 

1144 embedded_display_version or "unknown", 

1145 embedded_runtime_version or "unknown", 

1146 ota_display_version or "none", 

1147 ota_update_id or "none", 

1148 ).inc() 

1149 

1150 

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

1152# or never pulled. 

1153def _feature_flags_staleness_seconds() -> float: 

1154 return experimentation.seconds_since_last_fetch() or 0.0 

1155 

1156 

1157feature_flags_staleness_gauge: Gauge = Gauge( 

1158 "couchers_feature_flags_staleness_seconds", 

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

1160 multiprocess_mode="mostrecent", 

1161) 

1162_set_hacky_gauges_funcs.append((feature_flags_staleness_gauge, _feature_flags_staleness_seconds)) 

1163 

1164 

1165feature_flag_evaluations_counter: Counter = Counter( 

1166 "couchers_feature_flag_evaluations_total", 

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

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

1169) 

1170 

1171_MAX_FLAG_VALUE_LABEL_LEN = 32 

1172 

1173 

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

1175 if isinstance(value, bool): 

1176 return "true" if value else "false" 

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

1178 s = str(value) 

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

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

1181 return "None" 

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

1183 

1184 

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

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

1187 

1188 

1189# ============================================================================= 

1190# Rate limiting metrics (see couchers/middleware/ratelimit.py) 

1191# ============================================================================= 

1192 

1193# "shadowed" = a limit tripped but enforcement is off; "failed_open" = the store was unreachable so nothing 

1194# could be counted. Both let the request through. 

1195rate_limit_checks_counter: Counter = Counter( 

1196 "couchers_rate_limit_checks_total", 

1197 "Rate limit checks, by method and decision (allowed, shadowed, blocked, failed_open)", 

1198 labelnames=["method", "decision"], 

1199) 

1200 

1201 

1202def observe_rate_limit_check(method: str, decision: str) -> None: 

1203 rate_limit_checks_counter.labels(method, decision).inc() 

1204 

1205 

1206# one increment per counter that tripped, so a single request can trip several 

1207rate_limit_trips_counter: Counter = Counter( 

1208 "couchers_rate_limit_trips_total", 

1209 "Rate limit counters that tripped, by method, scope, dimension, and whether enforced", 

1210 labelnames=["method", "scope", "dimension", "enforced"], 

1211) 

1212 

1213 

1214def observe_rate_limit_trip(method: str, scope: str, dimension: str, enforced: bool) -> None: 

1215 rate_limit_trips_counter.labels(method, scope, dimension, "true" if enforced else "false").inc() 

1216 

1217 

1218# unlabelled by method: the work is the same whichever method is being counted, and a label would multiply 

1219# the series by the whole API surface 

1220rate_limit_duration_histogram: Histogram = Histogram( 

1221 "couchers_rate_limit_duration_seconds", 

1222 "Time spent in the rate limit check", 

1223 buckets=MACHINE_DURATION_SECONDS, 

1224) 

1225 

1226 

1227def observe_rate_limit_duration(duration_s: float) -> None: 

1228 rate_limit_duration_histogram.observe(duration_s) 

1229 

1230 

1231rate_limit_store_errors_counter: Counter = Counter( 

1232 "couchers_rate_limit_store_errors_total", 

1233 "Rate limit counter-store errors, which fail open, by exception type", 

1234 labelnames=["exception"], 

1235) 

1236 

1237 

1238def observe_rate_limit_store_error(exception_type: str) -> None: 

1239 rate_limit_store_errors_counter.labels(exception_type).inc() 

1240 

1241 

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

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

1244 

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

1246 # set hacky gauges 

1247 for gauge, f in _set_hacky_gauges_funcs: 

1248 gauge.set(f()) 

1249 for gauge, labeled_f in _set_hacky_labeled_gauges_funcs: 

1250 labeled_f(gauge) 

1251 for multi_f in _set_hacky_multi_gauges_funcs: 

1252 multi_f() 

1253 

1254 data = generate_latest(registry) 

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

1256 return [data] 

1257 

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

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

1260 ) 

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

1262 t.daemon = True 

1263 t.start() 

1264 return httpd