rails_pulse 0.4.2 → 0.5.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
@@ -2,10 +2,12 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static values = {
5
- type: String, // "bar", "line", "area", "sparkline"
6
- data: Object, // Chart data
7
- options: Object, // ECharts configuration
8
- theme: String // ECharts theme
5
+ type: String, // "bar", "line", "area", "sparkline"
6
+ data: Object, // Chart data
7
+ options: Object, // ECharts configuration
8
+ theme: String, // ECharts theme
9
+ timezone: String, // IANA zone summaries were bucketed in (e.g. "Etc/UTC")
10
+ timezoneLabel: String // Human label for the same zone (e.g. "UTC")
9
11
  }
10
12
 
11
13
  connect() {
@@ -401,6 +403,46 @@ export default class extends Controller {
401
403
  return this.getSafeFormatter(cleanString)
402
404
  }
403
405
 
406
+ // Formats in the aggregation zone, not the browser's — SummaryJob fixes a
407
+ // day's boundary at write time, so browser-zone display can flip the date.
408
+ formatDailyDate(date, extraOptions = {}) {
409
+ const timeZone = this.timezoneValue || undefined
410
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone, ...extraOptions })
411
+ }
412
+
413
+ // "Mon D, HH:MM" — the hourly-granularity counterpart to formatDailyDate.
414
+ formatDailyDateTime(date) {
415
+ const timeZone = this.timezoneValue || undefined
416
+ return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, timeZone })
417
+ }
418
+
419
+ // "HH:MM" only, same zone as the above.
420
+ formatHourMinute(date) {
421
+ const timeZone = this.timezoneValue || undefined
422
+ return date.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone })
423
+ }
424
+
425
+ // Year comparison in the same zone as the label it decorates, so a point
426
+ // near New Year is not tagged with a year the aggregation zone has not
427
+ // reached yet (or has already left).
428
+ isCurrentYear(date) {
429
+ const options = { year: 'numeric', timeZone: this.timezoneValue || undefined }
430
+ return date.toLocaleDateString('en-US', options) === new Date().toLocaleDateString('en-US', options)
431
+ }
432
+
433
+ // Hourly vs. daily comes from the formatter key Ruby chose, not ECharts'
434
+ // generated axisValueLabel (unreliable for some axis-pointer/series combos).
435
+ isHourlyAxis() {
436
+ return this.optionsValue?.xAxis?.axisLabel?.formatter === "time"
437
+ }
438
+
439
+ // Appends " (Zone)" to a tooltip's date/time string when a zone label is
440
+ // available, so a hover always says which zone the point is in.
441
+ withZoneSuffix(dateString) {
442
+ const zone = this.timezoneLabelValue || ''
443
+ return zone ? `${dateString} (${zone})` : dateString
444
+ }
445
+
404
446
  /**
405
447
  * Returns a safe formatter function based on the formatter string.
406
448
  * This prevents arbitrary code execution by using a whitelist approach.
@@ -455,7 +497,7 @@ export default class extends Controller {
455
497
  return value.toString()
456
498
  }
457
499
 
458
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
500
+ return this.formatDailyDate(date)
459
501
  }
460
502
  return value
461
503
  },
@@ -463,17 +505,14 @@ export default class extends Controller {
463
505
  // Time only (formatted as "HH:00" to match Rails Pulse formatters)
464
506
  'time': (value) => {
465
507
  if (typeof value === 'number' || typeof value === 'string') {
466
- // Convert to number if string
467
508
  const numValue = typeof value === 'string' ? parseInt(value) : value
468
509
  const date = new Date(numValue)
469
510
 
470
- const hours = date.getHours()
471
- if (isNaN(hours)) {
511
+ if (isNaN(date.getTime())) {
472
512
  return value.toString()
473
513
  }
474
514
 
475
- const minutes = date.getMinutes()
476
- return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0')
515
+ return this.formatHourMinute(date)
477
516
  }
478
517
  return value
479
518
  },
@@ -501,9 +540,7 @@ export default class extends Controller {
501
540
 
502
541
  const data = params[0]
503
542
  const date = new Date(data.axisValue)
504
- const hours = date.getHours().toString().padStart(2, '0')
505
- const minutes = date.getMinutes().toString().padStart(2, '0')
506
- const dateString = `${hours}:${minutes}`
543
+ const dateString = this.withZoneSuffix(this.formatHourMinute(date))
507
544
  const value = parseInt(data.data)
508
545
 
509
546
  return `${dateString} <br /> ${data.marker} ${value} ms`
@@ -515,7 +552,7 @@ export default class extends Controller {
515
552
 
516
553
  const data = params[0]
517
554
  const date = new Date(data.axisValue)
518
- const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
555
+ const dateString = this.withZoneSuffix(this.formatDailyDate(date))
519
556
  const value = parseInt(data.data)
520
557
 
521
558
  return `${dateString} <br /> ${data.marker} ${value} ms`
@@ -527,9 +564,7 @@ export default class extends Controller {
527
564
 
528
565
  const data = params[0]
529
566
  const date = new Date(data.axisValue)
530
- const hours = date.getHours().toString().padStart(2, '0')
531
- const minutes = date.getMinutes().toString().padStart(2, '0')
532
- const dateString = `${hours}:${minutes}`
567
+ const dateString = this.withZoneSuffix(this.formatHourMinute(date))
533
568
 
534
569
  return `${dateString} <br /> ${data.marker} ${data.data}`
535
570
  },
@@ -540,7 +575,7 @@ export default class extends Controller {
540
575
 
541
576
  const data = params[0]
542
577
  const date = new Date(data.axisValue)
543
- const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
578
+ const dateString = this.withZoneSuffix(this.formatDailyDate(date))
544
579
 
545
580
  return `${dateString} <br /> ${data.marker} ${data.data}`
546
581
  },
@@ -558,13 +593,14 @@ export default class extends Controller {
558
593
  const timestamp = Number(axisValue)
559
594
  const date = new Date(timestamp)
560
595
  if (!isNaN(date.getTime())) {
561
- dateString = date.toLocaleDateString('en-US', {
562
- month: 'short',
563
- day: 'numeric',
564
- year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
596
+ dateString = this.formatDailyDate(date, {
597
+ year: this.isCurrentYear(date) ? undefined : 'numeric'
565
598
  })
566
599
  }
567
600
  }
601
+ // Category axis sends an already-formatted label (e.g. "Apr 28")
602
+ // instead of a timestamp — still needs the suffix below.
603
+ dateString = this.withZoneSuffix(dateString)
568
604
 
569
605
  // Build tooltip HTML with all series
570
606
  let html = `${dateString}<br/>`
@@ -587,7 +623,7 @@ export default class extends Controller {
587
623
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
588
624
  const date = new Date(numValue)
589
625
  if (!isNaN(date.getTime())) {
590
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
626
+ return this.formatDailyDate(date)
591
627
  }
592
628
  }
593
629
 
@@ -599,23 +635,26 @@ export default class extends Controller {
599
635
  'tooltip_with_timestamp': (params) => {
600
636
  if (!Array.isArray(params) || params.length === 0) return ''
601
637
 
602
- // axisValueLabel is the pre-formatted axis label (e.g. "Apr/28" or "04:00")
603
- // axisValue is the raw value — a ms timestamp for time axis, category string otherwise
638
+ // axisValue is the raw value — a ms timestamp for time axis, an
639
+ // already-formatted category string (e.g. "Apr 28") otherwise
604
640
  const axisValue = params[0].axisValue
605
641
  const ts = Number(axisValue)
606
642
  let dateString
607
643
 
608
644
  if (!isNaN(ts) && ts > 1000000000000) {
609
645
  const date = new Date(ts)
610
- // Show time component if sub-day granularity (axis label is HH:mm style)
611
- const axisLabel = params[0].axisValueLabel || ''
612
- const isHourly = /^\d{2}:\d{2}$/.test(axisLabel)
613
- dateString = isHourly
614
- ? date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
615
- : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
646
+ // Sub-day granularity comes from the formatter Ruby chose for this
647
+ // chart, not from ECharts' own generated axisValueLabel (unreliable
648
+ // for some axis-pointer/series combinations — see isHourlyAxis()).
649
+ dateString = this.isHourlyAxis() ? this.formatDailyDateTime(date) : this.formatDailyDate(date)
616
650
  } else {
651
+ // Category axis (daily/weekly/monthly charts send a plain,
652
+ // already-formatted label like "Apr 28" instead of an epoch
653
+ // timestamp) — still needs the zone suffix, same as the time-axis
654
+ // branch above, or daily-grouped charts silently lose it.
617
655
  dateString = axisValue
618
656
  }
657
+ dateString = this.withZoneSuffix(dateString)
619
658
 
620
659
  // Build tooltip HTML with all series
621
660
  let html = `${dateString}<br/>`
@@ -643,12 +682,13 @@ export default class extends Controller {
643
682
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
644
683
  const date = new Date(numValue)
645
684
  if (!isNaN(date.getTime())) {
646
- const hours = date.getHours().toString().padStart(2, '0')
647
- const minutes = date.getMinutes().toString().padStart(2, '0')
648
- axisValue = `${hours}:${minutes}`
685
+ axisValue = this.formatHourMinute(date)
649
686
  }
650
687
  }
651
- // If it's already a formatted string (like "Apr 5"), leave it as-is
688
+ // Either the hour just computed above, or an already-formatted daily
689
+ // label (like "Apr 5") — both need the suffix, or daily sparklines
690
+ // silently lose it.
691
+ axisValue = this.withZoneSuffix(axisValue)
652
692
 
653
693
  // Show marker, series name, and value (e.g., "● P95: 150")
654
694
  return `${axisValue}<br/>${data.marker} ${seriesName}: ${value}`
@@ -669,11 +709,10 @@ export default class extends Controller {
669
709
  if (typeof numValue === 'number' && !isNaN(numValue) && numValue > 1000000000000) {
670
710
  const date = new Date(numValue)
671
711
  if (!isNaN(date.getTime())) {
672
- const hours = date.getHours().toString().padStart(2, '0')
673
- const minutes = date.getMinutes().toString().padStart(2, '0')
674
- axisValue = `${hours}:${minutes}`
712
+ axisValue = this.formatHourMinute(date)
675
713
  }
676
714
  }
715
+ axisValue = this.withZoneSuffix(axisValue)
677
716
 
678
717
  return `${axisValue}<br/>${data.marker} ${seriesName}: ${value}`
679
718
  },
@@ -688,14 +727,12 @@ export default class extends Controller {
688
727
 
689
728
  if (!isNaN(ts) && ts > 1000000000000) {
690
729
  const date = new Date(ts)
691
- const axisLabel = params[0].axisValueLabel || ''
692
- const isHourly = /^\d{2}:\d{2}$/.test(axisLabel)
693
- dateString = isHourly
694
- ? date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
695
- : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
730
+ dateString = this.isHourlyAxis() ? this.formatDailyDateTime(date) : this.formatDailyDate(date)
696
731
  } else {
732
+ // Category axis (daily/weekly/monthly) — see tooltip_with_timestamp.
697
733
  dateString = axisValue
698
734
  }
735
+ dateString = this.withZoneSuffix(dateString)
699
736
 
700
737
  let html = `${dateString}<br/>`
701
738
  params.forEach(param => {
@@ -6,8 +6,10 @@ module RailsPulse
6
6
  # @param period_types [Array<String>] Period types to backfill (default: ["hour", "day"])
7
7
  # @return [nil]
8
8
  def perform(start_date, end_date, period_types = [ "hour", "day" ])
9
- start_date = start_date.to_datetime
10
- end_date = end_date.to_datetime
9
+ # A Date or date string means midnight in the aggregation time zone, so
10
+ # backfilled periods land on the boundaries the scheduled job writes.
11
+ start_date = start_date.in_time_zone
12
+ end_date = end_date.in_time_zone
11
13
 
12
14
  period_types.each do |period_type|
13
15
  backfill_period(period_type, start_date, end_date)
@@ -0,0 +1,15 @@
1
+ module RailsPulse
2
+ module HasMetadata
3
+ extend ActiveSupport::Concern
4
+
5
+ # metadata can be valid JSON that isn't an object (an array, a scalar)
6
+ def metadata_hash
7
+ return {} if metadata.blank?
8
+
9
+ parsed = JSON.parse(metadata)
10
+ parsed.is_a?(Hash) ? parsed : {}
11
+ rescue JSON::ParserError
12
+ {}
13
+ end
14
+ end
15
+ end
@@ -5,25 +5,18 @@ module RailsPulse
5
5
  # Tag validation constants
6
6
  TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
7
7
  MAX_TAG_LENGTH = 50
8
- # Escape character for LIKE patterns over the serialized tags column.
9
- # Never appears in a valid tag name, and needs no quoting on any adapter.
10
- LIKE_ESCAPE = "!".freeze
11
-
12
8
  included do
13
9
  # Callbacks
14
10
  before_save :ensure_tags_is_array
15
11
 
16
- # Scopes with table name qualification to avoid ambiguity.
17
- # LIKE patterns are sanitized so `_`/`%` in a tag are matched literally.
18
- # The ESCAPE clause is explicit because SQLite has no default escape
19
- # character — without it `my\_tag` matches a literal backslash there
20
- # and the filter silently matches nothing. `!` is used rather than `\`
21
- # because MySQL and PostgreSQL disagree on how to quote a backslash.
12
+ # Scopes with table name qualification to avoid ambiguity. Tags are
13
+ # matched against the serialized column, so the quoted tag name is
14
+ # escaped by LikePattern and paired with its ESCAPE clause.
22
15
  scope :with_tag, ->(tag) {
23
- where("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
16
+ where("#{table_name}.tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}", "%\"#{RailsPulse::LikePattern.escape(tag)}\"%")
24
17
  }
25
18
  scope :without_tag, ->(tag) {
26
- where.not("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
19
+ where.not("#{table_name}.tags LIKE ? #{RailsPulse::LikePattern::CLAUSE}", "%\"#{RailsPulse::LikePattern.escape(tag)}\"%")
27
20
  }
28
21
  scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
29
22
  end
@@ -3,14 +3,21 @@ require "active_support/number_helper"
3
3
  module RailsPulse
4
4
  module Cards
5
5
  class Base
6
+ def initialize(window: nil, subject: nil, period: 7, period_type: "day", disabled_tags: [], show_non_tagged: true)
7
+ @window = window
8
+ @subject = subject
9
+ @period = period
10
+ @period_type = period_type
11
+ @disabled_tags = disabled_tags
12
+ @show_non_tagged = show_non_tagged
13
+ end
14
+
6
15
  private
7
16
 
8
- # nil unless the caller passed both start_time and end_time, so the
9
- # "trailing @period days/hours" fallback below still applies by default.
17
+ # nil unless the caller passed a window, so the "trailing @period
18
+ # days/hours" fallback below still applies by default.
10
19
  def time_window
11
- return @time_window if defined?(@time_window)
12
-
13
- @time_window = RailsPulse::TimeWindow.build(@start_time, @end_time)
20
+ @window
14
21
  end
15
22
 
16
23
  def now
@@ -74,14 +81,16 @@ module RailsPulse
74
81
  end
75
82
 
76
83
  def subject_id
77
- # Subclasses can override to provide @job.id, @query.id, @route.id
78
- instance_variable_get(:@job)&.id ||
79
- instance_variable_get(:@query)&.id ||
80
- instance_variable_get(:@route)&.id
84
+ @subject&.id
81
85
  end
82
86
 
83
- def quote(time)
84
- RailsPulse::Summary.connection.quote(time)
87
+ # A SELECT fragment comparing against the card's window, with the
88
+ # bounds bound as values (:current_start, :range_start) rather than
89
+ # interpolated into the SQL string.
90
+ def window_sql(fragment)
91
+ Arel.sql(
92
+ RailsPulse::Summary.sanitize_sql_array([ fragment, { current_start: current_window_start, range_start: range_start } ])
93
+ )
85
94
  end
86
95
 
87
96
  # Enhanced sparkline generation (support hour and day)
@@ -1,18 +1,14 @@
1
1
  module RailsPulse
2
2
  module Charts
3
3
  class Base
4
- def initialize(ransack_query:, period_type: nil, subject: nil, start_time: nil, end_time: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true, **options)
4
+ def initialize(ransack_query:, period_type: nil, subject: nil, window: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
5
5
  @ransack_query = ransack_query
6
6
  @period_type = period_type
7
7
  @subject = subject
8
- @start_time = start_time
9
- @end_time = end_time
8
+ @window = window
10
9
  @start_duration = start_duration
11
10
  @disabled_tags = disabled_tags
12
11
  @show_non_tagged = show_non_tagged
13
-
14
- # Support legacy parameter names (job, query, route)
15
- @subject ||= options[:job] || options[:query] || options[:route]
16
12
  end
17
13
 
18
14
  private
@@ -1,12 +1,11 @@
1
1
  module RailsPulse
2
2
  module Charts
3
3
  class PercentileChartBase
4
- def initialize(ransack_query:, period_type: nil, subject: nil, start_time: nil, end_time: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
4
+ def initialize(ransack_query:, period_type: nil, subject: nil, window: nil, start_duration: nil, disabled_tags: [], show_non_tagged: true)
5
5
  @ransack_query = ransack_query
6
6
  @period_type = period_type
7
7
  @subject = subject
8
- @start_time = start_time
9
- @end_time = end_time
8
+ @window = window
10
9
  @start_duration = start_duration
11
10
  @disabled_tags = disabled_tags
12
11
  @show_non_tagged = show_non_tagged
@@ -50,7 +49,7 @@ module RailsPulse
50
49
  # Convert to final values (weighted averages) and pad missing data
51
50
  step = @period_type.to_s == "hour" ? 3600 : 86400
52
51
  daily_data = {}
53
- (@start_time.to_i..@end_time.to_i).step(step) do |timestamp|
52
+ (@window.start_time.to_i..@window.end_time.to_i).step(step) do |timestamp|
54
53
  if raw_data[timestamp]
55
54
  count = raw_data[timestamp][:total_count]
56
55
  daily_data[timestamp] = {
@@ -2,13 +2,12 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ResponseTimePercentiles
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", window: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
- @start_time = start_time
11
- @end_time = end_time
10
+ @window = window
12
11
  end
13
12
 
14
13
  def to_chart_data
@@ -169,10 +168,10 @@ module RailsPulse
169
168
 
170
169
  private
171
170
 
172
- # nil unless both start_time and end_time were given, so the default
171
+ # nil unless a window was given, so the default
173
172
  # "recent" view keeps using the trailing-@period-days fallback below.
174
173
  def time_window
175
- @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
174
+ @window
176
175
  end
177
176
 
178
177
  def default_hour_range
@@ -2,13 +2,12 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ThroughputAndErrors
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", window: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
- @start_time = start_time
11
- @end_time = end_time
10
+ @window = window
12
11
  end
13
12
 
14
13
  def to_chart_data
@@ -125,10 +124,10 @@ module RailsPulse
125
124
 
126
125
  private
127
126
 
128
- # nil unless both start_time and end_time were given, so the default
127
+ # nil unless a window was given, so the default
129
128
  # "recent" view keeps using the trailing-@period-days fallback below.
130
129
  def time_window
131
- @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
130
+ @window
132
131
  end
133
132
 
134
133
  def default_hour_range
@@ -5,8 +5,7 @@ module RailsPulse
5
5
  private
6
6
 
7
7
  def period_range
8
- window = RailsPulse::TimeWindow.build(@start_time, @end_time)
9
- return [ window.start_time, window.end_time ] if window
8
+ return [ @window.start_time, @window.end_time ] if @window
10
9
 
11
10
  [ @period.days.ago.beginning_of_day, Time.current ]
12
11
  end
@@ -4,12 +4,11 @@ module RailsPulse
4
4
  include Concerns::ThresholdConstants
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, window: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
- @start_time = start_time
12
- @end_time = end_time
11
+ @window = window
13
12
  @route_thresholds = RailsPulse.configuration.route_thresholds
14
13
  @query_thresholds = RailsPulse.configuration.query_thresholds
15
14
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -22,6 +21,7 @@ module RailsPulse
22
21
  queries: query_counts,
23
22
  jobs: job_counts,
24
23
  exceptions: exception_counts,
24
+ tracking: tracking_counts,
25
25
  storage: storage_counts
26
26
  }
27
27
  end
@@ -93,6 +93,33 @@ module RailsPulse
93
93
  StoragePressure.new.storage_counts
94
94
  end
95
95
 
96
+ # A process that dropped and has since gone away still counts as
97
+ # critical for that hour. Nil until any writer has reported.
98
+ def tracking_counts
99
+ return nil unless RailsPulse::Event.table_available?
100
+
101
+ live = RailsPulse::WriterHeartbeat.live_processes
102
+ dropped_by_process = RailsPulse::WriterHeartbeat.dropped_by_process(window: 1.hour).select { |_, n| n.positive? }
103
+ return nil if live.empty? && dropped_by_process.empty?
104
+
105
+ critical = dropped_by_process.size
106
+ healthy = slow = 0
107
+ live.each do |process|
108
+ next if dropped_by_process.key?(process.process_label)
109
+
110
+ # queue_size <= 0 means unreadable metadata, not an empty queue
111
+ if process.queue_size <= 0
112
+ critical += 1
113
+ elsif process.queue_depth * 2 >= process.queue_size
114
+ slow += 1
115
+ else
116
+ healthy += 1
117
+ end
118
+ end
119
+
120
+ { healthy: healthy, slow: slow, critical: critical }
121
+ end
122
+
96
123
  def job_counts
97
124
  return nil unless RailsPulse.configuration.track_jobs
98
125
 
@@ -4,12 +4,11 @@ module RailsPulse
4
4
  include Concerns::ThresholdConstants
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, window: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
- @start_time = start_time
12
- @end_time = end_time
11
+ @window = window
13
12
  @route_thresholds = RailsPulse.configuration.route_thresholds
14
13
  @query_thresholds = RailsPulse.configuration.query_thresholds
15
14
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -9,11 +9,12 @@ module RailsPulse
9
9
  end
10
10
 
11
11
  def pressure_items
12
- summary_staleness_items + stuck_records_items + sub_hour_retention_items
12
+ summary_staleness_items + stuck_records_items + sub_hour_retention_items + writer_drop_items
13
13
  end
14
14
 
15
+ # Dropped requests get their own Tracking badge, not this one
15
16
  def storage_counts
16
- items = pressure_items
17
+ items = pressure_items.reject { |i| i[:type] == "TRACKING" }
17
18
  critical = items.any? { |i| i[:severity] == :critical } ? 1 : 0
18
19
  slow = (critical.zero? && items.any? { |i| i[:severity] == :warning }) ? 1 : 0
19
20
  healthy = (critical.zero? && slow.zero?) ? 1 : 0
@@ -36,11 +37,11 @@ module RailsPulse
36
37
  if age >= STALE_CRITICAL_THRESHOLD
37
38
  hours = (age / 1.hour).round
38
39
  [ stale_item(:critical, "Summary job is #{hours}h behind",
39
- "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M UTC")}") ]
40
+ "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M")} (#{RailsPulse::TimeRange.aggregation_zone_label})") ]
40
41
  elsif age >= STALE_WARNING_THRESHOLD
41
42
  hours = (age / 1.hour).round
42
43
  [ stale_item(:warning, "Summary job is #{hours}h behind",
43
- "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M UTC")}") ]
44
+ "#{hours}h stale", "Last run: #{latest_end.strftime("%Y-%m-%d %H:%M")} (#{RailsPulse::TimeRange.aggregation_zone_label})") ]
44
45
  else
45
46
  []
46
47
  end
@@ -107,6 +108,35 @@ module RailsPulse
107
108
  } ]
108
109
  end
109
110
 
111
+ # Signal D — the background writer is discarding requests
112
+ def writer_drop_items
113
+ return [] unless RailsPulse::Event.table_available?
114
+
115
+ summary = RailsPulse::WriterHeartbeat.summary
116
+ dropped = summary[:dropped].to_i
117
+ return [] if dropped.zero?
118
+
119
+ [ {
120
+ type: "TRACKING",
121
+ name: "Writer queue dropping requests",
122
+ reason: "#{dropped} #{"request".pluralize(dropped)} dropped in the last hour — charts are missing samples",
123
+ metric: "#{dropped} dropped",
124
+ metric_sub: "last hour, #{summary[:processes]} live #{"writer".pluralize(summary[:processes])}",
125
+ link: "#",
126
+ severity: :critical,
127
+ sort_score: dropped.to_f,
128
+ popover_title: "The tracking queue is overflowing",
129
+ popover_body: "Each process queues tracked requests for one background writer. When the queue (#{summary[:queue_size]} requests, " \
130
+ "<code>config.async_queue_size</code>) is full the newest request is dropped rather than slowing the app, so the " \
131
+ "dashboard undercounts traffic while this lasts.<br><br>" \
132
+ "<strong>How to fix:</strong> raise <code>config.async_queue_size</code> in the Rails Pulse initializer, or find out " \
133
+ "why the writer cannot keep up: database latency, a saturated connection pool, or a burst far above normal traffic. " \
134
+ "<code>rails rails_pulse:status</code> reports the same numbers from the shell."
135
+ } ]
136
+ rescue ActiveRecord::ActiveRecordError
137
+ []
138
+ end
139
+
110
140
  def stale_item(severity, reason, metric, metric_sub)
111
141
  {
112
142
  type: "STORAGE",