metrics.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """Prometheus metrics endpoint for external monitoring."""
  2. from fastapi import APIRouter, Depends, Header, HTTPException, Response
  3. from sqlalchemy import func, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.core.database import get_db
  6. from backend.app.models.archive import PrintArchive
  7. from backend.app.models.print_queue import PrintQueueItem
  8. from backend.app.models.printer import Printer
  9. from backend.app.models.settings import Settings
  10. from backend.app.services.printer_manager import printer_manager, supports_chamber_temp
  11. router = APIRouter(tags=["metrics"])
  12. async def get_prometheus_settings(db: AsyncSession) -> tuple[bool, str]:
  13. """Get Prometheus settings from database."""
  14. result = await db.execute(select(Settings).where(Settings.key.in_(["prometheus_enabled", "prometheus_token"])))
  15. settings_dict = {s.key: s.value for s in result.scalars().all()}
  16. enabled = settings_dict.get("prometheus_enabled", "false").lower() == "true"
  17. token = settings_dict.get("prometheus_token", "")
  18. return enabled, token
  19. def format_labels(**labels: str) -> str:
  20. """Format label key-value pairs for Prometheus."""
  21. if not labels:
  22. return ""
  23. pairs = [f'{k}="{v}"' for k, v in labels.items() if v is not None]
  24. return "{" + ",".join(pairs) + "}"
  25. def state_to_numeric(state: str) -> int:
  26. """Convert printer state string to numeric value."""
  27. state_map = {
  28. "unknown": 0,
  29. "IDLE": 1,
  30. "RUNNING": 2,
  31. "PAUSE": 3,
  32. "FINISH": 4,
  33. "FAILED": 5,
  34. "PREPARE": 6,
  35. "SLICING": 7,
  36. }
  37. return state_map.get(state, 0)
  38. @router.get("/metrics", response_class=Response)
  39. async def get_metrics(
  40. db: AsyncSession = Depends(get_db),
  41. authorization: str | None = Header(None),
  42. ):
  43. """
  44. Prometheus metrics endpoint.
  45. Returns metrics in Prometheus text exposition format.
  46. Requires prometheus_enabled setting to be true.
  47. If prometheus_token is set, requires Bearer token authentication.
  48. """
  49. # Check if enabled
  50. enabled, token = await get_prometheus_settings(db)
  51. if not enabled:
  52. raise HTTPException(status_code=404, detail="Prometheus metrics not enabled")
  53. # Check authentication if token is set
  54. if token:
  55. if not authorization:
  56. raise HTTPException(status_code=401, detail="Authorization required")
  57. if not authorization.startswith("Bearer "):
  58. raise HTTPException(status_code=401, detail="Bearer token required")
  59. provided_token = authorization[7:] # Remove "Bearer " prefix
  60. if provided_token != token:
  61. raise HTTPException(status_code=401, detail="Invalid token")
  62. lines: list[str] = []
  63. # =========================================================================
  64. # Printer metrics
  65. # =========================================================================
  66. # Get all printers from DB
  67. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  68. printers = list(result.scalars().all())
  69. # Build lookup for printer info
  70. printer_info = {p.id: p for p in printers}
  71. # Get all connected printer statuses
  72. all_statuses = printer_manager.get_all_statuses()
  73. # Printer connection status
  74. lines.append("# HELP bambuddy_printer_connected Printer connection status (1=connected, 0=disconnected)")
  75. lines.append("# TYPE bambuddy_printer_connected gauge")
  76. for printer in printers:
  77. status = all_statuses.get(printer.id)
  78. connected = 1 if status and status.connected else 0
  79. labels = format_labels(
  80. printer_id=str(printer.id),
  81. printer_name=printer.name,
  82. serial=printer.serial_number,
  83. model=printer.model or "unknown",
  84. )
  85. lines.append(f"bambuddy_printer_connected{labels} {connected}")
  86. # Printer state
  87. lines.append("")
  88. lines.append(
  89. "# HELP bambuddy_printer_state Printer state (0=unknown, 1=idle, 2=running, 3=pause, 4=finish, 5=failed, 6=prepare, 7=slicing)"
  90. )
  91. lines.append("# TYPE bambuddy_printer_state gauge")
  92. for printer in printers:
  93. status = all_statuses.get(printer.id)
  94. state_val = state_to_numeric(status.state) if status else 0
  95. labels = format_labels(
  96. printer_id=str(printer.id),
  97. printer_name=printer.name,
  98. serial=printer.serial_number,
  99. )
  100. lines.append(f"bambuddy_printer_state{labels} {state_val}")
  101. # Print progress
  102. lines.append("")
  103. lines.append("# HELP bambuddy_print_progress Current print progress (0-100)")
  104. lines.append("# TYPE bambuddy_print_progress gauge")
  105. for printer in printers:
  106. status = all_statuses.get(printer.id)
  107. progress = status.progress if status else 0
  108. labels = format_labels(
  109. printer_id=str(printer.id),
  110. printer_name=printer.name,
  111. serial=printer.serial_number,
  112. )
  113. lines.append(f"bambuddy_print_progress{labels} {progress:.1f}")
  114. # Remaining time
  115. lines.append("")
  116. lines.append("# HELP bambuddy_print_remaining_seconds Estimated remaining print time in seconds")
  117. lines.append("# TYPE bambuddy_print_remaining_seconds gauge")
  118. for printer in printers:
  119. status = all_statuses.get(printer.id)
  120. remaining = status.remaining_time * 60 if status else 0 # Convert minutes to seconds
  121. labels = format_labels(
  122. printer_id=str(printer.id),
  123. printer_name=printer.name,
  124. serial=printer.serial_number,
  125. )
  126. lines.append(f"bambuddy_print_remaining_seconds{labels} {remaining}")
  127. # Layer progress
  128. lines.append("")
  129. lines.append("# HELP bambuddy_print_layer_current Current layer number")
  130. lines.append("# TYPE bambuddy_print_layer_current gauge")
  131. for printer in printers:
  132. status = all_statuses.get(printer.id)
  133. layer = status.layer_num if status else 0
  134. labels = format_labels(
  135. printer_id=str(printer.id),
  136. printer_name=printer.name,
  137. serial=printer.serial_number,
  138. )
  139. lines.append(f"bambuddy_print_layer_current{labels} {layer}")
  140. lines.append("")
  141. lines.append("# HELP bambuddy_print_layer_total Total layers in current print")
  142. lines.append("# TYPE bambuddy_print_layer_total gauge")
  143. for printer in printers:
  144. status = all_statuses.get(printer.id)
  145. total = status.total_layers if status else 0
  146. labels = format_labels(
  147. printer_id=str(printer.id),
  148. printer_name=printer.name,
  149. serial=printer.serial_number,
  150. )
  151. lines.append(f"bambuddy_print_layer_total{labels} {total}")
  152. # =========================================================================
  153. # Temperature metrics
  154. # =========================================================================
  155. lines.append("")
  156. lines.append("# HELP bambuddy_bed_temp_celsius Current bed temperature")
  157. lines.append("# TYPE bambuddy_bed_temp_celsius gauge")
  158. for printer in printers:
  159. status = all_statuses.get(printer.id)
  160. temp = status.temperatures.get("bed", 0) if status else 0
  161. labels = format_labels(
  162. printer_id=str(printer.id),
  163. printer_name=printer.name,
  164. serial=printer.serial_number,
  165. )
  166. lines.append(f"bambuddy_bed_temp_celsius{labels} {temp:.1f}")
  167. lines.append("")
  168. lines.append("# HELP bambuddy_bed_target_celsius Target bed temperature")
  169. lines.append("# TYPE bambuddy_bed_target_celsius gauge")
  170. for printer in printers:
  171. status = all_statuses.get(printer.id)
  172. temp = status.temperatures.get("bed_target", 0) if status else 0
  173. labels = format_labels(
  174. printer_id=str(printer.id),
  175. printer_name=printer.name,
  176. serial=printer.serial_number,
  177. )
  178. lines.append(f"bambuddy_bed_target_celsius{labels} {temp:.1f}")
  179. lines.append("")
  180. lines.append("# HELP bambuddy_nozzle_temp_celsius Current nozzle temperature")
  181. lines.append("# TYPE bambuddy_nozzle_temp_celsius gauge")
  182. for printer in printers:
  183. status = all_statuses.get(printer.id)
  184. # Primary nozzle
  185. temp = status.temperatures.get("nozzle", 0) if status else 0
  186. labels = format_labels(
  187. printer_id=str(printer.id),
  188. printer_name=printer.name,
  189. serial=printer.serial_number,
  190. nozzle="0",
  191. )
  192. lines.append(f"bambuddy_nozzle_temp_celsius{labels} {temp:.1f}")
  193. # Second nozzle if present
  194. if status and "nozzle_2" in status.temperatures:
  195. temp2 = status.temperatures.get("nozzle_2", 0)
  196. labels2 = format_labels(
  197. printer_id=str(printer.id),
  198. printer_name=printer.name,
  199. serial=printer.serial_number,
  200. nozzle="1",
  201. )
  202. lines.append(f"bambuddy_nozzle_temp_celsius{labels2} {temp2:.1f}")
  203. lines.append("")
  204. lines.append("# HELP bambuddy_nozzle_target_celsius Target nozzle temperature")
  205. lines.append("# TYPE bambuddy_nozzle_target_celsius gauge")
  206. for printer in printers:
  207. status = all_statuses.get(printer.id)
  208. temp = status.temperatures.get("nozzle_target", 0) if status else 0
  209. labels = format_labels(
  210. printer_id=str(printer.id),
  211. printer_name=printer.name,
  212. serial=printer.serial_number,
  213. nozzle="0",
  214. )
  215. lines.append(f"bambuddy_nozzle_target_celsius{labels} {temp:.1f}")
  216. if status and "nozzle_2_target" in status.temperatures:
  217. temp2 = status.temperatures.get("nozzle_2_target", 0)
  218. labels2 = format_labels(
  219. printer_id=str(printer.id),
  220. printer_name=printer.name,
  221. serial=printer.serial_number,
  222. nozzle="1",
  223. )
  224. lines.append(f"bambuddy_nozzle_target_celsius{labels2} {temp2:.1f}")
  225. lines.append("")
  226. lines.append(
  227. "# HELP bambuddy_chamber_temp_celsius Current chamber temperature (only for models with chamber sensor)"
  228. )
  229. lines.append("# TYPE bambuddy_chamber_temp_celsius gauge")
  230. for printer in printers:
  231. # Only report chamber temp for models that have a real sensor
  232. if not supports_chamber_temp(printer.model):
  233. continue
  234. status = all_statuses.get(printer.id)
  235. temp = status.temperatures.get("chamber", 0) if status else 0
  236. labels = format_labels(
  237. printer_id=str(printer.id),
  238. printer_name=printer.name,
  239. serial=printer.serial_number,
  240. )
  241. lines.append(f"bambuddy_chamber_temp_celsius{labels} {temp:.1f}")
  242. # =========================================================================
  243. # Fan speeds
  244. # =========================================================================
  245. lines.append("")
  246. lines.append("# HELP bambuddy_fan_speed_percent Fan speed percentage")
  247. lines.append("# TYPE bambuddy_fan_speed_percent gauge")
  248. for printer in printers:
  249. status = all_statuses.get(printer.id)
  250. if not status:
  251. continue
  252. # Part cooling fan
  253. if "part_fan" in status.temperatures:
  254. val = status.temperatures["part_fan"]
  255. labels = format_labels(
  256. printer_id=str(printer.id),
  257. printer_name=printer.name,
  258. serial=printer.serial_number,
  259. fan="part",
  260. )
  261. lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
  262. # Aux fan
  263. if "aux_fan" in status.temperatures:
  264. val = status.temperatures["aux_fan"]
  265. labels = format_labels(
  266. printer_id=str(printer.id),
  267. printer_name=printer.name,
  268. serial=printer.serial_number,
  269. fan="aux",
  270. )
  271. lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
  272. # Chamber fan
  273. if "chamber_fan" in status.temperatures:
  274. val = status.temperatures["chamber_fan"]
  275. labels = format_labels(
  276. printer_id=str(printer.id),
  277. printer_name=printer.name,
  278. serial=printer.serial_number,
  279. fan="chamber",
  280. )
  281. lines.append(f"bambuddy_fan_speed_percent{labels} {val:.1f}")
  282. # =========================================================================
  283. # WiFi signal
  284. # =========================================================================
  285. lines.append("")
  286. lines.append("# HELP bambuddy_wifi_signal_dbm WiFi signal strength in dBm")
  287. lines.append("# TYPE bambuddy_wifi_signal_dbm gauge")
  288. for printer in printers:
  289. status = all_statuses.get(printer.id)
  290. if status and status.wifi_signal is not None:
  291. labels = format_labels(
  292. printer_id=str(printer.id),
  293. printer_name=printer.name,
  294. serial=printer.serial_number,
  295. )
  296. lines.append(f"bambuddy_wifi_signal_dbm{labels} {status.wifi_signal}")
  297. # =========================================================================
  298. # Print statistics (from database)
  299. # =========================================================================
  300. # Total prints by status
  301. lines.append("")
  302. lines.append("# HELP bambuddy_prints_total Total number of prints by result")
  303. lines.append("# TYPE bambuddy_prints_total counter")
  304. result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))
  305. for print_result, count in result.all():
  306. result_label = print_result or "unknown"
  307. labels = format_labels(result=result_label)
  308. lines.append(f"bambuddy_prints_total{labels} {count}")
  309. # Total prints per printer
  310. lines.append("")
  311. lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
  312. lines.append("# TYPE bambuddy_printer_prints_total counter")
  313. result = await db.execute(
  314. select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
  315. )
  316. for printer_id, count in result.all():
  317. if printer_id and printer_id in printer_info:
  318. p = printer_info[printer_id]
  319. labels = format_labels(
  320. printer_id=str(printer_id),
  321. printer_name=p.name,
  322. serial=p.serial_number,
  323. )
  324. lines.append(f"bambuddy_printer_prints_total{labels} {count}")
  325. # Total filament used (multiply by quantity to account for multiple items printed)
  326. lines.append("")
  327. lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
  328. lines.append("# TYPE bambuddy_filament_used_grams counter")
  329. result = await db.execute(
  330. select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
  331. )
  332. total_filament = result.scalar() or 0
  333. lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
  334. # Total print time
  335. lines.append("")
  336. lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
  337. lines.append("# TYPE bambuddy_print_time_seconds counter")
  338. result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))
  339. total_time = result.scalar() or 0
  340. lines.append(f"bambuddy_print_time_seconds {total_time}")
  341. # =========================================================================
  342. # Queue metrics
  343. # =========================================================================
  344. lines.append("")
  345. lines.append("# HELP bambuddy_queue_pending Number of pending queue items")
  346. lines.append("# TYPE bambuddy_queue_pending gauge")
  347. result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
  348. pending_count = result.scalar() or 0
  349. lines.append(f"bambuddy_queue_pending {pending_count}")
  350. lines.append("")
  351. lines.append("# HELP bambuddy_queue_printing Number of currently printing queue items")
  352. lines.append("# TYPE bambuddy_queue_printing gauge")
  353. result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "printing"))
  354. printing_count = result.scalar() or 0
  355. lines.append(f"bambuddy_queue_printing {printing_count}")
  356. # =========================================================================
  357. # System metrics
  358. # =========================================================================
  359. lines.append("")
  360. lines.append("# HELP bambuddy_printers_connected Number of connected printers")
  361. lines.append("# TYPE bambuddy_printers_connected gauge")
  362. connected_count = sum(1 for s in all_statuses.values() if s.connected)
  363. lines.append(f"bambuddy_printers_connected {connected_count}")
  364. lines.append("")
  365. lines.append("# HELP bambuddy_printers_total Total number of configured printers")
  366. lines.append("# TYPE bambuddy_printers_total gauge")
  367. lines.append(f"bambuddy_printers_total {len(printers)}")
  368. # Add trailing newline
  369. lines.append("")
  370. content = "\n".join(lines)
  371. return Response(content=content, media_type="text/plain; version=0.0.4; charset=utf-8")