exceptio 0.2.0

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +63 -0
  4. data/Rakefile +37 -0
  5. data/app/assets/config/exceptio_manifest.js +2 -0
  6. data/app/assets/javascripts/exceptio/application.js +2 -0
  7. data/app/assets/stylesheets/exceptio/application.css +15 -0
  8. data/app/controllers/exceptio/application_controller.rb +7 -0
  9. data/app/controllers/exceptio/exceptions_controller.rb +27 -0
  10. data/app/drops/exceptio/application_drop.rb +17 -0
  11. data/app/drops/exceptio/exception_drop.rb +9 -0
  12. data/app/drops/exceptio/instance_drop.rb +5 -0
  13. data/app/helpers/exceptio/application_helper.rb +13 -0
  14. data/app/jobs/exceptio/application_job.rb +4 -0
  15. data/app/mailers/exceptio/application_mailer.rb +6 -0
  16. data/app/models/exceptio/application_record.rb +9 -0
  17. data/app/models/exceptio/exception.rb +19 -0
  18. data/app/models/exceptio/instance.rb +44 -0
  19. data/app/services/exceptio/application_service.rb +22 -0
  20. data/app/services/exceptio/backtrace_enrichment_service.rb +59 -0
  21. data/app/services/exceptio/exception_recording_service.rb +51 -0
  22. data/app/tables/exceptio/exceptions_table.rb +58 -0
  23. data/app/views/exceptio/exception.html.slim +101 -0
  24. data/app/views/exceptio/exceptions/_trace_element.html.slim +15 -0
  25. data/app/views/exceptio/exceptions/index.html.slim +5 -0
  26. data/app/views/exceptio/exceptions/show.html.slim +74 -0
  27. data/app/views/exceptio/instances/_instance.html.slim +48 -0
  28. data/config/importmap.rb +4 -0
  29. data/config/locales/en.yml +16 -0
  30. data/config/locales/nl.yml +16 -0
  31. data/config/routes.rb +10 -0
  32. data/db/migrate/20190729153259_create_exceptions.rb +23 -0
  33. data/db/migrate/20201027102223_add_indices1.rb +8 -0
  34. data/db/migrate/20221229092150_add_ownable_to_instance.rb +6 -0
  35. data/lib/exceptio/engine.rb +28 -0
  36. data/lib/exceptio/rack/rails_instrumentation.rb +52 -0
  37. data/lib/exceptio/railtie.rb +19 -0
  38. data/lib/exceptio/recording.rb +92 -0
  39. data/lib/exceptio/version.rb +5 -0
  40. data/lib/exceptio.rb +69 -0
  41. metadata +222 -0
@@ -0,0 +1,74 @@
1
+ = sts.card :exceptio_exceptions, title: @exception.exception_class, description: @exception.code_location do |card|
2
+ - card.with_action
3
+ = link_to exception_path(@exception), data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' }, class: 'button'
4
+ i.fas.fa-trash
5
+ - card.with_tab :exception, padding: true
6
+ .grid.grid-cols-4.gap-4.mb-4
7
+ .col-span-3
8
+ h1 = @exception.exception_class
9
+ pre.text-xs style="height: 400px; overflow-y: scroll;"
10
+ code
11
+ = @exception.message
12
+ .col-span-1
13
+ = column_chart @exception.instances.group_by_day(:created_at).count
14
+
15
+ .grid.grid-cols-4.gap-4
16
+ .col-span-3
17
+ ul role="list" class="space-y-6"
18
+ = render partial: 'trace_element', collection: @exception.detailed_backtrace.map(&:symbolize_keys)
19
+ .col-span-1
20
+
21
+ - card.with_tab :instances, padding: true, badge: @exception.instances_count
22
+ .flow-root
23
+ ul.list-none.-mb-8
24
+ - @exception.instances.order(created_at: :desc).limit(10).each do |instance|
25
+ li
26
+ .relative.pb-8
27
+ span.absolute.left-5.top-5.-ml-px.h-full.w-0.5.bg-gray-200
28
+ .relative.flex.items-start.space-x-3
29
+ .relative
30
+ .flex.h-10.w-10.items-center.justify-center.rounded-full.bg-gray-100.ring-8.ring-white
31
+ - if instance.job?
32
+ i.fas.fa-dumbbell
33
+ - elsif instance.web?
34
+ i.fas.fa-globe
35
+
36
+ .min-w-0.flex-1
37
+ div
38
+ .text-sm
39
+ // could be a linK?
40
+ .font-medium.text-gray-900
41
+ = instance.message
42
+ p.mt-1.text-sm.text-gray-500
43
+ = ln(instance.created_at)
44
+ - if instance.browser
45
+ span.mr-1
46
+ - if instance.browser.chrome?
47
+ i.fab.fa-chrome
48
+ - elsif instance.browser.firefox?
49
+ i.fab.fa-chrome
50
+ - elsif instance.browser.edge?
51
+ i.fab.fa-edge
52
+ - else
53
+ i.fab.fa-safari
54
+ span.mr-1
55
+ - if instance.browser.platform.windows?
56
+ i.fab.fa-windows
57
+ - elsif instance.browser.platform.mac?
58
+ i.fab.fa-apple
59
+ - elsif instance.browser.platform.linux?
60
+ i.fab.fa-linux
61
+
62
+ .mt-2.text-sm.text-gray-700
63
+ = sts.info class: "grid grid-cols-1 gap-4 sm:grid-cols-3 mt-5" do |info|
64
+ = info.with_item :path, class: "sm:col-span-1"
65
+ - instance.request_env['REQUEST_URI']
66
+ = info.with_item :method, class: "sm:col-span-1"
67
+ - instance.request_env['REQUEST_METHOD']
68
+ = info.with_item :remote_ip, class: "sm:col-span-1"
69
+ - instance.request_env['REMOTE_ADDR']
70
+ = info.with_item :queue, class: "sm:col-span-1"
71
+ - instance.job['queue']
72
+ /= instance.context
73
+
74
+
@@ -0,0 +1,48 @@
1
+ .grid.grid-cols-1.gap-4.px-6.py-6.sm:grid-cols-3
2
+ .sm:col-span-3
3
+ = sts.info class: "grid grid-cols-1 gap-4 sm:grid-cols-2" do |info|
4
+ = info.with_item :error, content: instance.message, class: "sm:col-span-1"
5
+ = info.with_item :created_at, content: ln(instance.created_at), class: "sm:col-span-1", icon: 'fal fa-calendar'
6
+ = info.with_item :exceptionable_id, content: instance.exceptio_exceptionable_id, class: "sm:col-span-1"
7
+ = info.with_item :exceptionable_type, content: instance.exceptio_exceptionable_type, class: "sm:col-span-1"
8
+ br
9
+ - instance.related_data.each do |data|
10
+ .col-span-1.sm:col-span-1
11
+ = data.first.class.name
12
+ .col-span-1.sm:col-span-1
13
+ = link_to(data.second)
14
+ = data.last
15
+ - instance.context.each do |field, value|
16
+ .col-span-1.sm:col-span-1
17
+ = sts.card(title: field) do |card|
18
+ textarea data-controller="editor" data-editor-target="textarea" data-editor-mode="ruby" data-editor-height="400px" data-editor-readonly="true" = value
19
+ - if field == "job"
20
+ - gids = []
21
+ - hashes = eval(value)["args"].compact.map {|a| a.is_a?(Hash) ? a["arguments"] : nil}.compact.first&.select{|i| i.is_a?(Hash)} || []
22
+ - hashes.each do |h|
23
+ - h.each do |h1, v1|
24
+ - if v1.is_a?(Hash)
25
+ - gids << v1.values.first
26
+ - elsif v1.is_a?(String)
27
+ - gids << v1
28
+ - gids = gids.select{|k| k.start_with?("gid://")}
29
+ - GlobalID::Locator.locate_many(gids).each do |thing|
30
+ - if thing.is_a?(User)
31
+ = link_to(thing.name, admin_user_path(thing))
32
+ - elsif thing.is_a?(Account)
33
+ = link_to(thing.name, admin_account_path(thing))
34
+ - elsif thing.is_a?(Location)
35
+ = link_to(thing.name, admin_location_path(thing))
36
+ - elsif thing.is_a?(Order)
37
+ = link_to(thing.name, order_path(thing))
38
+ - elsif thing.is_a?(Shipment)
39
+ = link_to(thing.name, shipment_path(thing))
40
+ - elsif thing.is_a?(Nuntius::Message)
41
+ = link_to(thing.transport.upcase, Nuntius::Engine.routes.url_helpers.admin_message_path(thing))
42
+ - elsif thing.is_a?(Integration)
43
+ = link_to(thing.name, admin_integration_path(thing))
44
+ - elsif thing.is_a?(Vorto::Flow)
45
+ = link_to(thing.name, Vorto::Engine.routes.url_helpers.flow_path(thing))
46
+ - elsif ![Exceptio::Instance].member?(thing.class)
47
+ = link_to(thing)
48
+ br
@@ -0,0 +1,4 @@
1
+ pin "exceptio", to: "exceptio/application.js", preload: false
2
+
3
+ pin "chartkick", to: "chartkick.js"
4
+ pin "Chart.bundle", to: "Chart.bundle.js"
@@ -0,0 +1,16 @@
1
+ en:
2
+ exceptio:
3
+ exceptions:
4
+ index:
5
+ exceptions: Exceptions
6
+ show:
7
+ tabs:
8
+ main:
9
+ tab:
10
+ exception: Exceptions
11
+ instances: Instances
12
+ info_item:
13
+ main:
14
+ path: Path
15
+ method: Method
16
+ remote_ip: Extern IP
@@ -0,0 +1,16 @@
1
+ nl:
2
+ exceptio:
3
+ exceptions:
4
+ index:
5
+ exceptions: Execpties
6
+ show:
7
+ tabs:
8
+ main:
9
+ tab:
10
+ exception: Exceptie
11
+ instances: Instanties
12
+ info_item:
13
+ main:
14
+ path: Pad
15
+ method: Methode
16
+ remote_ip: Extern IP
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Exceptio::Engine.routes.draw do
4
+ root to: 'exceptions#index'
5
+ resources :exceptions do
6
+ collection do
7
+ post :delete_all
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,23 @@
1
+ class CreateExceptions < ActiveRecord::Migration[5.2]
2
+ def change
3
+ create_table :exceptio_exceptions, id: :uuid do |t|
4
+ t.string :exception_class, null: false
5
+ t.string :code_location, null: false
6
+ t.jsonb :detailed_backtrace, default: [], null: false
7
+ t.integer :instances_count, default: 0, null: false
8
+
9
+ t.timestamp :last_instance_at
10
+ t.timestamps null: false
11
+ end
12
+
13
+ create_table :exceptio_instances do |t|
14
+ t.uuid :exception_id, null: false
15
+ t.string :message, null: false
16
+ t.string :related_sgids, array: true, default: [], null: false
17
+ t.string :log_lines, array: true, default: [], null: false
18
+ t.jsonb :context, default: [], null: false
19
+
20
+ t.timestamps null: false
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ class AddIndices1 < ActiveRecord::Migration[5.2]
2
+ def change
3
+ add_index :exceptio_exceptions, :created_at
4
+ add_index :exceptio_exceptions, :updated_at
5
+ add_index :exceptio_exceptions, :last_instance_at
6
+ add_index :exceptio_instances, :exception_id
7
+ end
8
+ end
@@ -0,0 +1,6 @@
1
+ class AddOwnableToInstance < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :exceptio_instances, :exceptio_exceptionable_id, :uuid
4
+ add_column :exceptio_instances, :exceptio_exceptionable_type, :string
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ require 'slim'
2
+ require 'tailwindcss-rails'
3
+ require "importmap-rails"
4
+ require "turbo-rails"
5
+ require "stimulus-rails"
6
+
7
+ require 'chartkick'
8
+ require 'groupdate'
9
+
10
+ module Exceptio
11
+ class Engine < ::Rails::Engine
12
+ isolate_namespace Exceptio
13
+
14
+ initializer 'exceptio.assets' do |app|
15
+ app.config.assets.paths << root.join("app/javascript")
16
+ app.config.assets.paths << root.join("app/components")
17
+ app.config.assets.paths << Exceptio::Engine.root.join("vendor/javascript")
18
+ app.config.assets.precompile += %w[exceptio_manifest]
19
+ end
20
+
21
+ initializer 'exceptio.importmap', before: "importmap" do |app|
22
+ app.config.importmap.paths << root.join("config/importmap.rb")
23
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
24
+ app.config.importmap.cache_sweepers << root.join("app/components")
25
+ app.config.importmap.cache_sweepers << Exceptio::Engine.root.join("vendor/javascript")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ require "rack"
2
+
3
+ module Exceptio
4
+ # @api private
5
+ module Rack
6
+ class RailsInstrumentation
7
+ def initialize(app, options = {})
8
+ # Exceptio.logger.debug 'Initializing Exceptio::Rack::RailsInstrumentation'
9
+ @app = app
10
+ @options = options
11
+ end
12
+
13
+ def call(env)
14
+ if Exceptio.active?
15
+ call_with_exceptio(env)
16
+ else
17
+ @app.call(env)
18
+ end
19
+ end
20
+
21
+ def call_with_exceptio(env)
22
+ request = ActionDispatch::Request.new(env)
23
+
24
+ recording = Exceptio::Recording.create(
25
+ request_id(env),
26
+ 'http_request',
27
+ request,
28
+ :params_method => :filtered_parameters
29
+ )
30
+ begin
31
+ @app.call(env)
32
+ rescue Exception => error # rubocop:disable Lint/RescueException
33
+ recording.set_error(error)
34
+ controller = env['action_controller.instance']
35
+ if controller
36
+ recording.set_metadata('action', "#{controller.class}##{controller.action_name}")
37
+ end
38
+ recording.set_metadata('path', request.path)
39
+ recording.set_metadata('method', request.request_method)
40
+ Exceptio::Recording.store!
41
+ raise error
42
+ ensure
43
+ Exceptio::Recording.clear_current_recording!
44
+ end
45
+ end
46
+
47
+ def request_id(env)
48
+ env['action_dispatch.request_id'] || SecureRandom.uuid
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ Exceptio.logger.info("Loading Rails (#{Rails.version}) integration")
2
+
3
+ require "exceptio/rack/rails_instrumentation"
4
+
5
+ module Exceptio
6
+ # @api private
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "exceptio.configure_rails_initialization" do |app|
9
+ Exceptio::Railtie.initialize_exceptio(app)
10
+ end
11
+
12
+ def self.initialize_exceptio(app)
13
+ app.middleware.insert_after(
14
+ ActionDispatch::DebugExceptions,
15
+ Exceptio::Rack::RailsInstrumentation
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,92 @@
1
+ require "exceptio/rack/rails_instrumentation"
2
+
3
+ module Exceptio
4
+ # @api private
5
+ class Recording
6
+
7
+ attr_reader :recording_id
8
+
9
+ class << self
10
+ def create(recording_id, namespace, request, options = {})
11
+ # Allow middleware to force a new recording
12
+ if options.include?(:force) && options[:force]
13
+ Thread.current[:exceptio_recording] = nil
14
+ end
15
+
16
+ # Check if we already have a running recording
17
+ if Thread.current[:exceptio_recording] != nil
18
+ # Log the issue and return the current recording
19
+ Exceptio.logger.debug "Trying to start new recording with id " \
20
+ "'#{recording_id}', but a recording with id '#{current.recording_id}' " \
21
+ "is already running. Using recording '#{current.recording_id}'."
22
+
23
+ # Return the current (running) recording
24
+ current
25
+ else
26
+ # Otherwise, start a new exceptio_recording
27
+ Thread.current[:exceptio_recording] = Exceptio::Recording.new(recording_id, namespace, request, options)
28
+ end
29
+ end
30
+
31
+ def current
32
+ Thread.current[:exceptio_recording]
33
+ end
34
+
35
+ def store!
36
+ current.store
37
+ rescue => e
38
+ Exceptio.logger.error("Failed to complete recording ##{current.recording_id}. #{e.message}")
39
+ ensure
40
+ clear_current_recording!
41
+ end
42
+
43
+ # Remove current recording from current Thread.
44
+ # @api private
45
+ def clear_current_recording!
46
+ Thread.current[:exceptio_recording] = nil
47
+ end
48
+ end
49
+
50
+ def initialize(recording_id, namespace, request, options = {})
51
+ @recording_id = recording_id
52
+ @action = nil
53
+ @namespace = namespace
54
+ @request = request
55
+ @tags = {}
56
+ @options = options
57
+ @options[:params_method] ||= :params
58
+ @error = nil
59
+ @metadata = {}
60
+ end
61
+
62
+ def set_error(error)
63
+ return unless error
64
+
65
+ backtrace = cleaned_backtrace(error.backtrace)
66
+ @error = { class_name: error.class.name, message: error.message.to_s, backtrace: backtrace || [] }
67
+ end
68
+
69
+ def set_metadata(key, value = '')
70
+ @metadata[key] = value
71
+ end
72
+
73
+ def cleaned_backtrace(backtrace)
74
+ if defined?(::Rails) && backtrace
75
+ ::Rails.backtrace_cleaner.clean(backtrace, nil)
76
+ else
77
+ backtrace
78
+ end
79
+ end
80
+
81
+ def environment
82
+ return {} unless request.respond_to?(:env)
83
+ return {} unless request.env
84
+
85
+ request.env
86
+ end
87
+
88
+ def store
89
+ Exceptio.logger.debug "recording: #{self.inspect}"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exceptio
4
+ VERSION = '0.2.0'
5
+ end
data/lib/exceptio.rb ADDED
@@ -0,0 +1,69 @@
1
+ require "exceptio/engine"
2
+
3
+ module Exceptio
4
+ class Error < StandardError; end
5
+
6
+ class Configuration
7
+ attr_accessor :base_controller, :max_occurences
8
+ attr_writer :logger, :related_sgids, :sgid_to_object_url_name, :context_details, :after_exception, :layout, :admin_layout
9
+
10
+ def initialize
11
+ @logger = Logger.new(STDOUT)
12
+ @logger.level = Logger::WARN
13
+ @base_controller = '::ApplicationController'
14
+ @context_details = -> { {} }
15
+ @related_sgids = -> { [] }
16
+ @sgid_to_object_url_name = -> { nil }
17
+ @after_exception = ->(exception, instance) {}
18
+ @max_occurences = 100
19
+ @layout = 'application'
20
+ @admin_layout = 'application'
21
+ end
22
+
23
+ # Config: logger [Object].
24
+ def logger
25
+ @logger.is_a?(Proc) ? instance_exec(&@logger) : @logger
26
+ end
27
+
28
+ # Proc returning array of sgids of relevant objects, like current user or account
29
+ def related_sgids
30
+ instance_exec(&@related_sgids) if @related_sgids.is_a?(Proc)
31
+ end
32
+
33
+ # Proc returning [object, path, name] for the specified sgid (path to and name of user or account)
34
+ def sgid_to_object_url_name(sgid)
35
+ instance_exec(sgid, &@sgid_to_object_url_name) if @sgid_to_object_url_name.is_a?(Proc)
36
+ end
37
+
38
+ # Proc returning extra environment details (hash) to be stored with the exception
39
+ def context_details
40
+ context = instance_exec(&@context_details) if @context_details.is_a?(Proc)
41
+ context || {}
42
+ end
43
+
44
+ def after_exception(exception, instance)
45
+ instance_exec(exception, instance, &@after_exception) if @after_exception.is_a?(Proc)
46
+ end
47
+ end
48
+
49
+ class << self
50
+ def config
51
+ @config ||= Configuration.new
52
+ end
53
+
54
+ def setup
55
+ yield config
56
+ end
57
+
58
+ def logger
59
+ config.logger
60
+ end
61
+
62
+ def active?
63
+ false
64
+ end
65
+ end
66
+ end
67
+
68
+ require "exceptio/railtie" if defined?(::Rails)
69
+ require "exceptio/recording"