newshound 0.2.0 → 0.2.2
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 +49 -0
- data/Gemfile +8 -9
- data/README.md +15 -0
- data/Rakefile +8 -3
- data/lib/generators/newshound/install/install_generator.rb +1 -1
- data/lib/generators/newshound/install/templates/newshound.rb +5 -1
- data/lib/newshound/configuration.rb +3 -2
- data/lib/newshound/exception_reporter.rb +16 -38
- data/lib/newshound/exceptions/base.rb +40 -0
- data/lib/newshound/exceptions/exception_track.rb +92 -0
- data/lib/newshound/exceptions/solid_errors.rb +97 -0
- data/lib/newshound/exceptions.rb +12 -0
- data/lib/newshound/middleware/banner_injector.rb +49 -15
- data/lib/newshound/que_reporter.rb +70 -32
- data/lib/newshound/railtie.rb +3 -3
- data/lib/newshound/version.rb +2 -2
- data/lib/newshound.rb +2 -1
- data/newshound.gemspec +5 -6
- metadata +13 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65debb3ae756e51f870de0872ff54d2332ee71e9c05aa6ac174c9525336c46b5
|
|
4
|
+
data.tar.gz: 8667bbc9ff8695b5191bccbbe066190bb9e6f86f7c7ef0d6508a312420258167
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7bb18eaf72c010172a7c80c13f782b6736620cef9855ee81f0faaa2ad32d1659b02d0970a4381adad7b42d72eb80663b8c93e0eefebd58d61ec75d45c2c31081
|
|
7
|
+
data.tar.gz: '07722335323043284857b615205ec4def0f167f4f77308ffce3eeb1911afe85c963e6a62987f88d88d59e8385a4998d30e33835211c6c7d70f52e111f46ae5bb'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.2] - 2025-10-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- CI workflow to run tests on Ruby 3.0-3.4 (b3ebc7a)
|
|
13
|
+
- Separate linter job for RuboCop (b3ebc7a)
|
|
14
|
+
- Coverage reporting with SimpleCov (b3ebc7a)
|
|
15
|
+
- CI workflow to run tests on Ruby 3.0-3.4 (782cc57)
|
|
16
|
+
- Separate linter job for RuboCop (782cc57)
|
|
17
|
+
- Coverage reporting with SimpleCov (782cc57)
|
|
18
|
+
- .claude/settings.local.json to .gitignore (701d802)
|
|
19
|
+
- Reissue gem dependency for automated versioning (feac98a)
|
|
20
|
+
- Git trailer support for changelog management (feac98a)
|
|
21
|
+
- Initial CHANGELOG.md with Keep a Changelog format (feac98a)
|
|
22
|
+
- CODEOWNERS file for SOFware/engineers (1a0a1f5)
|
|
23
|
+
- Newshound::Exceptions::Base for standard API to interact with exception data (9dce846)
|
|
24
|
+
- Configuration exception_source to allow for future alternative exception backends (9dce846)
|
|
25
|
+
- SolidErrors support (8b5b48a)
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Replaced RuboCop with StandardRB for simpler linting (09292ed)
|
|
30
|
+
- Updated GitHub Actions workflow to use standardrb (09292ed)
|
|
31
|
+
- Auto-corrected all StandardRB violations (09292ed)
|
|
32
|
+
- Simplified test setup by removing unused mocks (701d802)
|
|
33
|
+
- Rakefile to use reissue tasks instead of manual versioning (feac98a)
|
|
34
|
+
- Removed Ruby 3.0 from test matrix (now testing 3.1-3.4) (4beafd2)
|
|
35
|
+
- Updated gemspec required_ruby_version to >= 3.1.0 (4beafd2)
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Workflow now only runs once per PR (removed duplicate triggers) (09292ed)
|
|
40
|
+
- Mock ActiveRecord::Base.connection and its methods (701d802)
|
|
41
|
+
- Mock connection.execute to return database-like results (701d802)
|
|
42
|
+
- Mock connection.quote and connection.select_value properly (701d802)
|
|
43
|
+
- Name and email in gemspec authors (ab44f30)
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
|
|
47
|
+
- Dependency on exception-track (8b5b48a)
|
|
48
|
+
|
|
49
|
+
## [0.2.2] - Unreleased
|
data/Gemfile
CHANGED
|
@@ -4,12 +4,11 @@ source "https://rubygems.org"
|
|
|
4
4
|
|
|
5
5
|
gemspec
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
end
|
|
7
|
+
gem "bundler"
|
|
8
|
+
gem "rake"
|
|
9
|
+
gem "rspec"
|
|
10
|
+
gem "reissue"
|
|
11
|
+
gem "standard"
|
|
12
|
+
gem "pg"
|
|
13
|
+
gem "pry"
|
|
14
|
+
gem "simplecov"
|
data/README.md
CHANGED
|
@@ -201,6 +201,21 @@ bundle exec rubocop
|
|
|
201
201
|
bin/console
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
+
## Release Management
|
|
205
|
+
|
|
206
|
+
This gem uses [Reissue](https://github.com/rails/reissue) for release management. To release a new version, perform
|
|
207
|
+
the following steps as you would with any other ruby gem:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
bundle exec rake bump:checksum
|
|
211
|
+
```
|
|
212
|
+
And then create a new release:
|
|
213
|
+
```bash
|
|
214
|
+
bundle exec rake release
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The final step is to push your version bump branch, open a PR, and merge it.
|
|
218
|
+
|
|
204
219
|
## Dependencies
|
|
205
220
|
|
|
206
221
|
- **Rails** >= 6.0
|
data/Rakefile
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
+
require "reissue/gem"
|
|
4
5
|
require "rspec/core/rake_task"
|
|
5
|
-
require "
|
|
6
|
+
require "standard/rake"
|
|
7
|
+
|
|
8
|
+
Reissue::Task.create :reissue do |task|
|
|
9
|
+
task.version_file = "lib/newshound/version.rb"
|
|
10
|
+
task.fragment = :git
|
|
11
|
+
end
|
|
6
12
|
|
|
7
13
|
RSpec::Core::RakeTask.new(:spec)
|
|
8
|
-
RuboCop::RakeTask.new
|
|
9
14
|
|
|
10
|
-
task default: %i[spec
|
|
15
|
+
task default: %i[spec standard]
|
|
@@ -10,6 +10,10 @@ Newshound.configure do |config|
|
|
|
10
10
|
# Default is 10
|
|
11
11
|
config.exception_limit = 10
|
|
12
12
|
|
|
13
|
+
# Exception source to use for exceptions
|
|
14
|
+
# Default is :exception_track
|
|
15
|
+
config.exception_source = :exception_track # or :solid_errors
|
|
16
|
+
|
|
13
17
|
# User roles that are authorized to view the Newshound banner
|
|
14
18
|
# These should match the role values in your User model
|
|
15
19
|
# Default is [:developer, :super_user]
|
|
@@ -29,4 +33,4 @@ end
|
|
|
29
33
|
# # Your custom logic here
|
|
30
34
|
# # Return true to show the banner, false to hide it
|
|
31
35
|
# controller.current_user&.admin?
|
|
32
|
-
# end
|
|
36
|
+
# end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Newshound
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :exception_limit, :enabled, :authorized_roles,
|
|
6
|
-
|
|
6
|
+
:current_user_method, :authorization_block, :exception_source
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@exception_limit = 10
|
|
@@ -11,6 +11,7 @@ module Newshound
|
|
|
11
11
|
@authorized_roles = [:developer, :super_user]
|
|
12
12
|
@current_user_method = :current_user
|
|
13
13
|
@authorization_block = nil
|
|
14
|
+
@exception_source = :exception_track
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# Allow custom authorization logic
|
|
@@ -22,4 +23,4 @@ module Newshound
|
|
|
22
23
|
enabled
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
|
-
end
|
|
26
|
+
end
|
|
@@ -5,8 +5,8 @@ module Newshound
|
|
|
5
5
|
attr_reader :exception_source, :configuration, :time_range
|
|
6
6
|
|
|
7
7
|
def initialize(exception_source: nil, configuration: nil, time_range: 24.hours)
|
|
8
|
-
@exception_source = exception_source || (defined?(ExceptionTrack::Log) ? ExceptionTrack::Log : nil)
|
|
9
8
|
@configuration = configuration || Newshound.configuration
|
|
9
|
+
@exception_source = resolve_exception_source(exception_source)
|
|
10
10
|
@time_range = time_range
|
|
11
11
|
end
|
|
12
12
|
|
|
@@ -24,20 +24,24 @@ module Newshound
|
|
|
24
24
|
*format_exceptions
|
|
25
25
|
]
|
|
26
26
|
end
|
|
27
|
+
alias_method :report, :generate_report
|
|
28
|
+
|
|
29
|
+
# Returns data formatted for the banner UI
|
|
30
|
+
def banner_data
|
|
31
|
+
{
|
|
32
|
+
exceptions: recent_exceptions.map { |exception| exception_source.format_for_banner(exception) }
|
|
33
|
+
}
|
|
34
|
+
end
|
|
27
35
|
|
|
28
36
|
private
|
|
29
37
|
|
|
30
|
-
def
|
|
31
|
-
|
|
38
|
+
def resolve_exception_source(source)
|
|
39
|
+
source ||= @configuration.exception_source
|
|
40
|
+
source.is_a?(Symbol) ? Exceptions.source(source) : source
|
|
32
41
|
end
|
|
33
42
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
exception_source
|
|
38
|
-
.where("created_at >= ?", time_range.ago)
|
|
39
|
-
.order(created_at: :desc)
|
|
40
|
-
.limit(configuration.exception_limit)
|
|
43
|
+
def recent_exceptions
|
|
44
|
+
@recent_exceptions ||= exception_source.recent(time_range: time_range, limit: configuration.exception_limit)
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
def format_exceptions
|
|
@@ -46,38 +50,12 @@ module Newshound
|
|
|
46
50
|
type: "section",
|
|
47
51
|
text: {
|
|
48
52
|
type: "mrkdwn",
|
|
49
|
-
text:
|
|
53
|
+
text: exception_source.format_for_report(exception, index + 1)
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
55
|
-
def format_exception_text(exception, number)
|
|
56
|
-
<<~TEXT
|
|
57
|
-
*#{number}. #{exception.title || exception.exception_class}*
|
|
58
|
-
• *Time:* #{exception.created_at.strftime('%I:%M %p')}
|
|
59
|
-
• *Controller:* #{exception.controller_name}##{exception.action_name}
|
|
60
|
-
• *Count:* #{exception_count(exception)}
|
|
61
|
-
#{format_message(exception)}
|
|
62
|
-
TEXT
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def format_message(exception)
|
|
66
|
-
return "" unless exception.message.present?
|
|
67
|
-
|
|
68
|
-
message = exception.message.truncate(100)
|
|
69
|
-
"• *Message:* `#{message}`"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def exception_count(exception)
|
|
73
|
-
return 0 unless exception_source
|
|
74
|
-
|
|
75
|
-
exception_source
|
|
76
|
-
.where(exception_class: exception.exception_class)
|
|
77
|
-
.where("created_at >= ?", time_range.ago)
|
|
78
|
-
.count
|
|
79
|
-
end
|
|
80
|
-
|
|
81
59
|
def no_exceptions_block
|
|
82
60
|
[
|
|
83
61
|
{
|
|
@@ -90,4 +68,4 @@ module Newshound
|
|
|
90
68
|
]
|
|
91
69
|
end
|
|
92
70
|
end
|
|
93
|
-
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Newshound
|
|
2
|
+
module Exceptions
|
|
3
|
+
# Base class for exception source adapters
|
|
4
|
+
# Each adapter is responsible for:
|
|
5
|
+
# 1. Fetching recent exceptions from its specific exception tracking system
|
|
6
|
+
# 2. Formatting exception data for reports and banners
|
|
7
|
+
#
|
|
8
|
+
# Subclasses must implement:
|
|
9
|
+
# - #recent(time_range:, limit:) - Returns a collection of exception records
|
|
10
|
+
# - #format_for_report(exception) - Formats a single exception for Slack/report display
|
|
11
|
+
# - #format_for_banner(exception) - Formats a single exception for banner UI
|
|
12
|
+
class Base
|
|
13
|
+
# Fetches recent exceptions from the exception tracking system
|
|
14
|
+
#
|
|
15
|
+
# @param time_range [ActiveSupport::Duration] Time duration to look back (e.g., 24.hours)
|
|
16
|
+
# @param limit [Integer] Maximum number of exceptions to return
|
|
17
|
+
# @return [Array] Collection of exception records
|
|
18
|
+
def recent(time_range:, limit:)
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #recent"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Formats an exception for report/Slack display
|
|
23
|
+
#
|
|
24
|
+
# @param exception [Object] Exception record from the tracking system
|
|
25
|
+
# @param number [Integer] Position number in the list
|
|
26
|
+
# @return [String] Formatted markdown text for display
|
|
27
|
+
def format_for_report(exception, number)
|
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #format_for_report"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Formats an exception for banner UI display
|
|
32
|
+
#
|
|
33
|
+
# @param exception [Object] Exception record from the tracking system
|
|
34
|
+
# @return [Hash] Hash with keys: :title, :message, :location, :time
|
|
35
|
+
def format_for_banner(exception)
|
|
36
|
+
raise NotImplementedError, "#{self.class} must implement #format_for_banner"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Newshound
|
|
2
|
+
module Exceptions
|
|
3
|
+
class ExceptionTrack < Base
|
|
4
|
+
def recent(time_range:, limit:)
|
|
5
|
+
::ExceptionTrack::Log
|
|
6
|
+
.where("created_at >= ?", time_range.ago)
|
|
7
|
+
.order(created_at: :desc)
|
|
8
|
+
.limit(limit)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def format_for_report(exception, number)
|
|
12
|
+
details = parse_exception_details(exception)
|
|
13
|
+
|
|
14
|
+
<<~TEXT
|
|
15
|
+
*#{number}. #{exception_title(exception)}*
|
|
16
|
+
• *Time:* #{exception.created_at.strftime("%I:%M %p")}
|
|
17
|
+
#{format_controller(details)}
|
|
18
|
+
#{format_message(exception, details)}
|
|
19
|
+
TEXT
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_for_banner(exception)
|
|
23
|
+
details = parse_exception_details(exception)
|
|
24
|
+
|
|
25
|
+
# Extract message
|
|
26
|
+
message = if details["message"].present?
|
|
27
|
+
details["message"].to_s
|
|
28
|
+
elsif exception.respond_to?(:message) && exception.message.present?
|
|
29
|
+
exception.message.to_s
|
|
30
|
+
else
|
|
31
|
+
+""
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Extract location
|
|
35
|
+
location = if details["controller_name"] && details["action_name"]
|
|
36
|
+
"#{details["controller_name"]}##{details["action_name"]}"
|
|
37
|
+
else
|
|
38
|
+
+""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
title: exception_title(exception),
|
|
43
|
+
message: message.truncate(100),
|
|
44
|
+
location: location,
|
|
45
|
+
time: exception.created_at.strftime("%I:%M %p")
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def exception_title(exception)
|
|
52
|
+
if exception.respond_to?(:title) && exception.title.present?
|
|
53
|
+
exception.title
|
|
54
|
+
elsif exception.respond_to?(:exception_class) && exception.exception_class.present?
|
|
55
|
+
exception.exception_class
|
|
56
|
+
else
|
|
57
|
+
"Unknown Exception"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_exception_details(exception)
|
|
62
|
+
return {} unless exception.respond_to?(:body) && exception.body.present?
|
|
63
|
+
|
|
64
|
+
JSON.parse(exception.body)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_controller(details)
|
|
70
|
+
return +"" unless details["controller_name"] && details["action_name"]
|
|
71
|
+
|
|
72
|
+
"• *Controller:* #{details["controller_name"]}##{details["action_name"]}\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_message(exception, details = nil)
|
|
76
|
+
details ||= parse_exception_details(exception)
|
|
77
|
+
|
|
78
|
+
# Try to get message from different sources
|
|
79
|
+
message = if details["message"].present?
|
|
80
|
+
details["message"]
|
|
81
|
+
elsif exception.respond_to?(:message) && exception.message.present?
|
|
82
|
+
exception.message
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
return +"" unless message.present?
|
|
86
|
+
|
|
87
|
+
message = message.to_s.truncate(100)
|
|
88
|
+
"• *Message:* `#{message}`"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Newshound
|
|
2
|
+
module Exceptions
|
|
3
|
+
class SolidErrors < Base
|
|
4
|
+
def recent(time_range:, limit:)
|
|
5
|
+
::SolidErrors::Occurrence
|
|
6
|
+
.where("created_at >= ?", time_range.ago)
|
|
7
|
+
.order(created_at: :desc)
|
|
8
|
+
.limit(limit)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def format_for_report(exception, number)
|
|
12
|
+
context = parse_context(exception)
|
|
13
|
+
|
|
14
|
+
<<~TEXT
|
|
15
|
+
*#{number}. #{exception_title(exception)}*
|
|
16
|
+
• *Time:* #{exception.created_at.strftime("%I:%M %p")}
|
|
17
|
+
#{format_controller(context)}
|
|
18
|
+
#{format_message(exception, context)}
|
|
19
|
+
TEXT
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_for_banner(exception)
|
|
23
|
+
context = parse_context(exception)
|
|
24
|
+
|
|
25
|
+
# Extract message
|
|
26
|
+
message = if exception.respond_to?(:message) && exception.message.present?
|
|
27
|
+
exception.message.to_s
|
|
28
|
+
elsif context["message"].present?
|
|
29
|
+
context["message"].to_s
|
|
30
|
+
else
|
|
31
|
+
+""
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Extract location from context
|
|
35
|
+
location = if context["controller"] && context["action"]
|
|
36
|
+
"#{context["controller"]}##{context["action"]}"
|
|
37
|
+
else
|
|
38
|
+
+""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
title: exception_title(exception),
|
|
43
|
+
message: message.truncate(100),
|
|
44
|
+
location: location,
|
|
45
|
+
time: exception.created_at.strftime("%I:%M %p")
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def exception_title(exception)
|
|
52
|
+
if exception.respond_to?(:error_class) && exception.error_class.present?
|
|
53
|
+
exception.error_class
|
|
54
|
+
else
|
|
55
|
+
"Unknown Exception"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_context(exception)
|
|
60
|
+
return {} unless exception.respond_to?(:context) && exception.context.present?
|
|
61
|
+
|
|
62
|
+
# SolidErrors context might be a hash or JSON string
|
|
63
|
+
if exception.context.is_a?(Hash)
|
|
64
|
+
exception.context
|
|
65
|
+
elsif exception.context.is_a?(String)
|
|
66
|
+
JSON.parse(exception.context)
|
|
67
|
+
else
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def format_controller(context)
|
|
75
|
+
return +"" unless context["controller"] && context["action"]
|
|
76
|
+
|
|
77
|
+
"• *Controller:* #{context["controller"]}##{context["action"]}\n"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def format_message(exception, context = nil)
|
|
81
|
+
context ||= parse_context(exception)
|
|
82
|
+
|
|
83
|
+
# Try to get message from different sources
|
|
84
|
+
message = if exception.respond_to?(:message) && exception.message.present?
|
|
85
|
+
exception.message
|
|
86
|
+
elsif context["message"].present?
|
|
87
|
+
context["message"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return +"" unless message.present?
|
|
91
|
+
|
|
92
|
+
message = message.to_s.truncate(100)
|
|
93
|
+
"• *Message:* `#{message}`"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Dir[File.join(__dir__, "exceptions", "*.rb")].each { |file| require file }
|
|
2
|
+
|
|
3
|
+
module Newshound
|
|
4
|
+
module Exceptions
|
|
5
|
+
def self.source(source)
|
|
6
|
+
constant = constants.find { |c| c.to_s.gsub(/(?<!^)([A-Z])/, "_\\1").downcase == source.to_s }
|
|
7
|
+
raise "Invalid exception source: #{source}" unless constant
|
|
8
|
+
|
|
9
|
+
const_get(constant).new
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -15,7 +15,7 @@ module Newshound
|
|
|
15
15
|
return [status, headers, response] unless status == 200
|
|
16
16
|
|
|
17
17
|
# Check authorization
|
|
18
|
-
controller = env[
|
|
18
|
+
controller = env["action_controller.instance"]
|
|
19
19
|
return [status, headers, response] unless controller
|
|
20
20
|
return [status, headers, response] unless Newshound::Authorization.authorized?(controller)
|
|
21
21
|
|
|
@@ -26,8 +26,8 @@ module Newshound
|
|
|
26
26
|
new_response = inject_banner(response, banner_html)
|
|
27
27
|
|
|
28
28
|
# Update Content-Length header
|
|
29
|
-
if headers[
|
|
30
|
-
headers[
|
|
29
|
+
if headers["Content-Length"]
|
|
30
|
+
headers["Content-Length"] = new_response.bytesize.to_s
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
[status, headers, [new_response]]
|
|
@@ -36,8 +36,8 @@ module Newshound
|
|
|
36
36
|
private
|
|
37
37
|
|
|
38
38
|
def html_response?(headers)
|
|
39
|
-
content_type = headers[
|
|
40
|
-
content_type
|
|
39
|
+
content_type = headers["Content-Type"]
|
|
40
|
+
content_type&.include?("text/html")
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def inject_banner(response, banner_html)
|
|
@@ -48,7 +48,7 @@ module Newshound
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def response_body(response)
|
|
51
|
-
body = ""
|
|
51
|
+
body = +""
|
|
52
52
|
response.each { |part| body << part }
|
|
53
53
|
body
|
|
54
54
|
end
|
|
@@ -57,8 +57,8 @@ module Newshound
|
|
|
57
57
|
exception_reporter = Newshound::ExceptionReporter.new
|
|
58
58
|
que_reporter = Newshound::QueReporter.new
|
|
59
59
|
|
|
60
|
-
exception_data = exception_reporter.
|
|
61
|
-
job_data = que_reporter.
|
|
60
|
+
exception_data = exception_reporter.banner_data
|
|
61
|
+
job_data = que_reporter.banner_data
|
|
62
62
|
|
|
63
63
|
# Generate HTML from template
|
|
64
64
|
render_banner(exception_data, job_data)
|
|
@@ -68,7 +68,7 @@ module Newshound
|
|
|
68
68
|
<<~HTML
|
|
69
69
|
<div id="newshound-banner" class="newshound-banner newshound-collapsed">
|
|
70
70
|
#{render_styles}
|
|
71
|
-
<div class="newshound-header" onclick="document.getElementById('newshound-banner').classList.toggle('newshound-collapsed')">
|
|
71
|
+
<div class="newshound-header" onclick="document.getElementById('newshound-banner').classList.toggle('newshound-collapsed'); window.newshoundUpdatePadding();">
|
|
72
72
|
<span class="newshound-title">
|
|
73
73
|
🐕 Newshound
|
|
74
74
|
#{summary_badge(exception_data, job_data)}
|
|
@@ -80,12 +80,46 @@ module Newshound
|
|
|
80
80
|
#{render_jobs(job_data)}
|
|
81
81
|
</div>
|
|
82
82
|
</div>
|
|
83
|
+
#{render_script}
|
|
83
84
|
HTML
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
def render_script
|
|
88
|
+
<<~JS
|
|
89
|
+
<script>
|
|
90
|
+
(function() {
|
|
91
|
+
window.newshoundUpdatePadding = function() {
|
|
92
|
+
// Wait for transition to complete
|
|
93
|
+
setTimeout(function() {
|
|
94
|
+
var banner = document.getElementById('newshound-banner');
|
|
95
|
+
if (banner) {
|
|
96
|
+
var height = banner.offsetHeight;
|
|
97
|
+
document.body.style.paddingTop = height + 'px';
|
|
98
|
+
}
|
|
99
|
+
}, 300);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Set initial padding after page load
|
|
103
|
+
if (document.readyState === 'loading') {
|
|
104
|
+
document.addEventListener('DOMContentLoaded', window.newshoundUpdatePadding);
|
|
105
|
+
} else {
|
|
106
|
+
window.newshoundUpdatePadding();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Also update on window resize
|
|
110
|
+
window.addEventListener('resize', window.newshoundUpdatePadding);
|
|
111
|
+
})();
|
|
112
|
+
</script>
|
|
113
|
+
JS
|
|
114
|
+
end
|
|
115
|
+
|
|
86
116
|
def render_styles
|
|
87
117
|
<<~CSS
|
|
88
118
|
<style>
|
|
119
|
+
body {
|
|
120
|
+
padding-top: 0;
|
|
121
|
+
transition: padding-top 0.3s ease-out;
|
|
122
|
+
}
|
|
89
123
|
.newshound-banner {
|
|
90
124
|
position: fixed;
|
|
91
125
|
top: 0;
|
|
@@ -275,13 +309,13 @@ module Newshound
|
|
|
275
309
|
end
|
|
276
310
|
|
|
277
311
|
def escape_html(text)
|
|
278
|
-
return "" unless text
|
|
312
|
+
return +"" unless text.present?
|
|
279
313
|
text.to_s
|
|
280
|
-
.gsub(
|
|
281
|
-
.gsub(
|
|
282
|
-
.gsub(
|
|
283
|
-
.gsub('"',
|
|
284
|
-
.gsub("'",
|
|
314
|
+
.gsub("&", "&")
|
|
315
|
+
.gsub("<", "<")
|
|
316
|
+
.gsub(">", ">")
|
|
317
|
+
.gsub('"', """)
|
|
318
|
+
.gsub("'", "'")
|
|
285
319
|
end
|
|
286
320
|
end
|
|
287
321
|
end
|
|
@@ -6,7 +6,7 @@ module Newshound
|
|
|
6
6
|
|
|
7
7
|
def initialize(job_source: nil, logger: nil)
|
|
8
8
|
@job_source = job_source || (defined?(::Que::Job) ? ::Que::Job : nil)
|
|
9
|
-
@logger = logger || (defined?(Rails) ? Rails.logger : Logger.new(
|
|
9
|
+
@logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def generate_report
|
|
@@ -22,14 +22,29 @@ module Newshound
|
|
|
22
22
|
queue_health_section
|
|
23
23
|
].compact
|
|
24
24
|
end
|
|
25
|
+
alias_method :report, :generate_report
|
|
26
|
+
|
|
27
|
+
# Returns data formatted for the banner UI
|
|
28
|
+
def banner_data
|
|
29
|
+
stats = queue_statistics
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
queue_stats: {
|
|
33
|
+
ready_to_run: stats[:ready],
|
|
34
|
+
scheduled: stats[:scheduled],
|
|
35
|
+
failed: stats[:failed],
|
|
36
|
+
completed_today: stats[:finished_today]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
25
40
|
|
|
26
41
|
private
|
|
27
42
|
|
|
28
43
|
def job_counts_section
|
|
29
44
|
counts = job_counts_by_type
|
|
30
|
-
|
|
45
|
+
|
|
31
46
|
return no_jobs_section if counts.empty?
|
|
32
|
-
|
|
47
|
+
|
|
33
48
|
{
|
|
34
49
|
type: "section",
|
|
35
50
|
text: {
|
|
@@ -42,37 +57,49 @@ module Newshound
|
|
|
42
57
|
def job_counts_by_type
|
|
43
58
|
return {} unless job_source
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
# Use raw SQL since Que::Job may not support ActiveRecord's .group method
|
|
61
|
+
results = ActiveRecord::Base.connection.execute(<<~SQL)
|
|
62
|
+
SELECT job_class, error_count, COUNT(*) as count
|
|
63
|
+
FROM que_jobs
|
|
64
|
+
WHERE finished_at IS NULL
|
|
65
|
+
GROUP BY job_class, error_count
|
|
66
|
+
ORDER BY job_class
|
|
67
|
+
SQL
|
|
68
|
+
|
|
69
|
+
results.each_with_object({}) do |row, hash|
|
|
70
|
+
job_class = row["job_class"]
|
|
71
|
+
error_count = row["error_count"].to_i
|
|
72
|
+
count = row["count"].to_i
|
|
73
|
+
|
|
74
|
+
hash[job_class] ||= {success: 0, failed: 0, total: 0}
|
|
75
|
+
|
|
76
|
+
if error_count.zero?
|
|
77
|
+
hash[job_class][:success] += count
|
|
78
|
+
else
|
|
79
|
+
hash[job_class][:failed] += count
|
|
59
80
|
end
|
|
81
|
+
|
|
82
|
+
hash[job_class][:total] += count
|
|
83
|
+
end
|
|
84
|
+
rescue => e
|
|
85
|
+
logger.error "Failed to fetch job counts: #{e.message}"
|
|
86
|
+
{}
|
|
60
87
|
end
|
|
61
88
|
|
|
62
89
|
def format_job_counts(counts)
|
|
63
90
|
lines = ["*Job Counts by Type:*"]
|
|
64
|
-
|
|
91
|
+
|
|
65
92
|
counts.each do |job_class, stats|
|
|
66
|
-
status_emoji = stats[:failed] > 0 ? "⚠️" : "✅"
|
|
93
|
+
status_emoji = (stats[:failed] > 0) ? "⚠️" : "✅"
|
|
67
94
|
lines << "• #{status_emoji} *#{job_class}*: #{stats[:total]} total (#{stats[:success]} success, #{stats[:failed]} failed)"
|
|
68
95
|
end
|
|
69
|
-
|
|
96
|
+
|
|
70
97
|
lines.join("\n")
|
|
71
98
|
end
|
|
72
99
|
|
|
73
100
|
def queue_health_section
|
|
74
101
|
stats = queue_statistics
|
|
75
|
-
|
|
102
|
+
|
|
76
103
|
{
|
|
77
104
|
type: "section",
|
|
78
105
|
text: {
|
|
@@ -85,27 +112,38 @@ module Newshound
|
|
|
85
112
|
def queue_statistics
|
|
86
113
|
return default_stats unless job_source
|
|
87
114
|
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
conn = ActiveRecord::Base.connection
|
|
116
|
+
current_time = conn.quote(Time.now)
|
|
117
|
+
beginning_of_day = conn.quote(Date.today.to_time)
|
|
90
118
|
|
|
91
119
|
{
|
|
92
|
-
ready:
|
|
93
|
-
scheduled:
|
|
94
|
-
failed:
|
|
95
|
-
finished_today:
|
|
120
|
+
ready: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at <= #{current_time}"),
|
|
121
|
+
scheduled: count_jobs("finished_at IS NULL AND expired_at IS NULL AND run_at > #{current_time}"),
|
|
122
|
+
failed: count_jobs("error_count > 0 AND finished_at IS NULL"),
|
|
123
|
+
finished_today: count_jobs("finished_at >= #{beginning_of_day}")
|
|
96
124
|
}
|
|
97
|
-
rescue
|
|
125
|
+
rescue => e
|
|
98
126
|
logger.error "Failed to fetch Que statistics: #{e.message}"
|
|
99
127
|
default_stats
|
|
100
128
|
end
|
|
101
129
|
|
|
130
|
+
def count_jobs(where_clause)
|
|
131
|
+
ActiveRecord::Base.connection.select_value(
|
|
132
|
+
"SELECT COUNT(*) FROM que_jobs WHERE #{where_clause}"
|
|
133
|
+
).to_i
|
|
134
|
+
end
|
|
135
|
+
|
|
102
136
|
def default_stats
|
|
103
|
-
{
|
|
137
|
+
{ready: 0, scheduled: 0, failed: 0, finished_today: 0}
|
|
104
138
|
end
|
|
105
139
|
|
|
106
140
|
def format_queue_health(stats)
|
|
107
|
-
health_emoji = stats[:failed] > 10
|
|
108
|
-
|
|
141
|
+
health_emoji = if stats[:failed] > 10
|
|
142
|
+
"🔴"
|
|
143
|
+
else
|
|
144
|
+
(stats[:failed] > 5) ? "🟡" : "🟢"
|
|
145
|
+
end
|
|
146
|
+
|
|
109
147
|
<<~TEXT
|
|
110
148
|
*Queue Health #{health_emoji}*
|
|
111
149
|
• *Ready to Run:* #{stats[:ready]}
|
|
@@ -125,4 +163,4 @@ module Newshound
|
|
|
125
163
|
}
|
|
126
164
|
end
|
|
127
165
|
end
|
|
128
|
-
end
|
|
166
|
+
end
|
data/lib/newshound/railtie.rb
CHANGED
|
@@ -19,9 +19,9 @@ module Newshound
|
|
|
19
19
|
puts "Newshound Configuration:"
|
|
20
20
|
puts " Enabled: #{config.enabled}"
|
|
21
21
|
puts " Exception Limit: #{config.exception_limit}"
|
|
22
|
-
puts " Authorized Roles: #{config.authorized_roles.join(
|
|
22
|
+
puts " Authorized Roles: #{config.authorized_roles.join(", ")}"
|
|
23
23
|
puts " Current User Method: #{config.current_user_method}"
|
|
24
|
-
puts " Custom Authorization: #{config.authorization_block.present? ?
|
|
24
|
+
puts " Custom Authorization: #{config.authorization_block.present? ? "Yes" : "No"}"
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
desc "Test exception reporter"
|
|
@@ -56,4 +56,4 @@ module Newshound
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
|
-
end
|
|
59
|
+
end
|
data/lib/newshound/version.rb
CHANGED
data/lib/newshound.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "newshound/version"
|
|
4
4
|
require_relative "newshound/configuration"
|
|
5
|
+
require_relative "newshound/exceptions"
|
|
5
6
|
require_relative "newshound/exception_reporter"
|
|
6
7
|
require_relative "newshound/que_reporter"
|
|
7
8
|
require_relative "newshound/authorization"
|
|
@@ -25,4 +26,4 @@ module Newshound
|
|
|
25
26
|
configuration.authorize_with(&block)
|
|
26
27
|
end
|
|
27
28
|
end
|
|
28
|
-
end
|
|
29
|
+
end
|
data/newshound.gemspec
CHANGED
|
@@ -5,14 +5,14 @@ require_relative "lib/newshound/version"
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
6
|
spec.name = "newshound"
|
|
7
7
|
spec.version = Newshound::VERSION
|
|
8
|
-
spec.authors = ["
|
|
9
|
-
spec.email = ["
|
|
8
|
+
spec.authors = ["Savannah Moore"]
|
|
9
|
+
spec.email = ["savannah.albanez@sofwarellc.com"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Real-time web UI banner for monitoring Que jobs and exception tracking"
|
|
12
12
|
spec.description = "Newshound displays exceptions and job statuses in a collapsible banner for authorized users in your Rails app"
|
|
13
|
-
spec.homepage = "https://github.com/
|
|
13
|
+
spec.homepage = "https://github.com/SOFware/newshound"
|
|
14
14
|
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">=
|
|
15
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
16
16
|
|
|
17
17
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
18
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
@@ -30,5 +30,4 @@ Gem::Specification.new do |spec|
|
|
|
30
30
|
# Runtime dependencies
|
|
31
31
|
spec.add_dependency "rails", ">= 6.0"
|
|
32
32
|
spec.add_dependency "que", ">= 1.0"
|
|
33
|
-
|
|
34
|
-
end
|
|
33
|
+
end
|
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: newshound
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Savannah Moore
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -37,30 +37,17 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '1.0'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: exception-track
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - ">="
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.1'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.1'
|
|
54
40
|
description: Newshound displays exceptions and job statuses in a collapsible banner
|
|
55
41
|
for authorized users in your Rails app
|
|
56
42
|
email:
|
|
57
|
-
-
|
|
43
|
+
- savannah.albanez@sofwarellc.com
|
|
58
44
|
executables: []
|
|
59
45
|
extensions: []
|
|
60
46
|
extra_rdoc_files: []
|
|
61
47
|
files:
|
|
62
48
|
- ".rspec"
|
|
63
49
|
- ".tool-versions"
|
|
50
|
+
- CHANGELOG.md
|
|
64
51
|
- Gemfile
|
|
65
52
|
- README.md
|
|
66
53
|
- Rakefile
|
|
@@ -71,18 +58,22 @@ files:
|
|
|
71
58
|
- lib/newshound/authorization.rb
|
|
72
59
|
- lib/newshound/configuration.rb
|
|
73
60
|
- lib/newshound/exception_reporter.rb
|
|
61
|
+
- lib/newshound/exceptions.rb
|
|
62
|
+
- lib/newshound/exceptions/base.rb
|
|
63
|
+
- lib/newshound/exceptions/exception_track.rb
|
|
64
|
+
- lib/newshound/exceptions/solid_errors.rb
|
|
74
65
|
- lib/newshound/middleware/banner_injector.rb
|
|
75
66
|
- lib/newshound/que_reporter.rb
|
|
76
67
|
- lib/newshound/railtie.rb
|
|
77
68
|
- lib/newshound/version.rb
|
|
78
69
|
- newshound.gemspec
|
|
79
|
-
homepage: https://github.com/
|
|
70
|
+
homepage: https://github.com/SOFware/newshound
|
|
80
71
|
licenses:
|
|
81
72
|
- MIT
|
|
82
73
|
metadata:
|
|
83
|
-
homepage_uri: https://github.com/
|
|
84
|
-
source_code_uri: https://github.com/
|
|
85
|
-
changelog_uri: https://github.com/
|
|
74
|
+
homepage_uri: https://github.com/SOFware/newshound
|
|
75
|
+
source_code_uri: https://github.com/SOFware/newshound
|
|
76
|
+
changelog_uri: https://github.com/SOFware/newshound/blob/main/CHANGELOG.md
|
|
86
77
|
rdoc_options: []
|
|
87
78
|
require_paths:
|
|
88
79
|
- lib
|
|
@@ -90,7 +81,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
90
81
|
requirements:
|
|
91
82
|
- - ">="
|
|
92
83
|
- !ruby/object:Gem::Version
|
|
93
|
-
version:
|
|
84
|
+
version: 3.1.0
|
|
94
85
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
86
|
requirements:
|
|
96
87
|
- - ">="
|