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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +2 -6
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
- data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
- data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +40 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
- data/app/models/rails_pulse/like_pattern.rb +29 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +37 -0
- data/config/routes.rb +19 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
- data/lib/rails_pulse/cleanup_service.rb +26 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +40 -0
- data/lib/rails_pulse/cli/exceptions.rb +111 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +72 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +62 -0
- data/lib/rails_pulse/cli/requests.rb +49 -0
- data/lib/rails_pulse/cli/routes.rb +66 -0
- data/lib/rails_pulse/configuration.rb +23 -1
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +76 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
- data/lib/rails_pulse/tracker.rb +75 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +96 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- 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,
|
|
6
|
-
data: Object,
|
|
7
|
-
options: Object,
|
|
8
|
-
theme: String
|
|
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
|
|
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
|
-
|
|
471
|
-
if (isNaN(hours)) {
|
|
511
|
+
if (isNaN(date.getTime())) {
|
|
472
512
|
return value.toString()
|
|
473
513
|
}
|
|
474
514
|
|
|
475
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
562
|
-
|
|
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
|
|
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
|
-
//
|
|
603
|
-
//
|
|
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
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
dateString =
|
|
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
|
-
|
|
647
|
-
const minutes = date.getMinutes().toString().padStart(2, '0')
|
|
648
|
-
axisValue = `${hours}:${minutes}`
|
|
685
|
+
axisValue = this.formatHourMinute(date)
|
|
649
686
|
}
|
|
650
687
|
}
|
|
651
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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 ?
|
|
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 ?
|
|
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
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
@
|
|
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,
|
|
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
|
-
@
|
|
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",
|
|
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
|
-
@
|
|
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
|
|
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
|
-
@
|
|
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",
|
|
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
|
-
@
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
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,
|
|
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
|
-
@
|
|
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,
|
|
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
|
-
@
|
|
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
|
|
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
|
|
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",
|