errorgap 0.6.0 → 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75cf1e4a725d5e1c7f484a577814d71403fbdc4e6c6cd4bcec3d0eca30f00478
4
- data.tar.gz: 98d336466f4eceedc7ad6fc03fff038704d10872e30c6b7f5376528e424dd73b
3
+ metadata.gz: 0b1e9118a26db2870eb0aa90b9a4ef3784138e078d03b2959ce1b064949b53de
4
+ data.tar.gz: e3da3e394f5aa7961c5956f8fdfa5346696086324b48eedac330e0d95dd42f59
5
5
  SHA512:
6
- metadata.gz: 44e3afd205bce67d34e40e6b71a5c67b43615dc04acc299f7188f0e01d514844e7b836523a0dcae0e95388b7b38875c76f2ffc012d3a83b316450182b5a2fb36
7
- data.tar.gz: b8b2d440abe90925b5750cf6526b34449d00d913d5bd47a8d74339ce8ba9c5622abe55949b66465ad1f1f0b0972ee26446c258086a98280c94dcdff36b66a229
6
+ metadata.gz: b8d0f1bae2b1df08c0893e414a8916a4cc7487afa5443eb3c1de0860133baca93d71c29935552c158ec93392b878b44933848e53ed5761aca84fd141014d2702
7
+ data.tar.gz: a9ba148ab19bba740b6d877403ee628c43f303a27abfdd20347a5728d7860ea1ec792739b3b23c5b1992066cdd309ac457fd28de60fd9bb7cca4af19bddc49b6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-10-06
9
+
10
+ ### Added
11
+
12
+ - **Sign-ins.** `config.auth_events = true` reports sign-ins to errorgap's
13
+ Security › Logins (`POST /logins/web`). Devise and other Warden apps are
14
+ covered automatically: successful sign-ins and failed sign-in attempts
15
+ (`locked` for locked accounts), without remember-me cookies, signed-out
16
+ page visits or expired sessions. `Errorgap.sign_in(outcome, user:,
17
+ request:)` reports anything else (`password_reset`, `mfa_failure`, …).
18
+ `config.app_name` and `config.auth_user` control how the app and user are
19
+ named. Passwords and tokens are never sent.
20
+
21
+ ## [0.7.0] - 2026-10-03
22
+
23
+ ### Added
24
+
25
+ - **Releases.** `config.release` (default `ENV["ERRORGAP_RELEASE"]`) is sent
26
+ as `context.release` on every notice, tying errors to the deploy that
27
+ shipped them.
28
+ - **Browser trace links.** The Rack middleware reads the `x-errorgap-trace`
29
+ header sent by `@errorgap/browser` 0.3+ and records it as the transaction's
30
+ `trace_id`, so errorgap links a browser API call to the server request that
31
+ answered it. Only well-formed UUIDs are accepted.
32
+
8
33
  ## [0.6.0] - 2026-10-03
9
34
 
10
35
  ### Added
data/README.md CHANGED
@@ -31,9 +31,18 @@ Errorgap.configure do |config|
31
31
  # Skip reporting entirely (errors and APM) in these environments.
32
32
  # Also configurable via ERRORGAP_IGNORE_ENVIRONMENTS="test,development".
33
33
  config.ignore_environments = %w[test development]
34
+
35
+ # The deployed version or commit (defaults to ERRORGAP_RELEASE). Errors carry
36
+ # it, so errorgap ties each one to the deploy that shipped it.
37
+ config.release = ENV["GIT_SHA"]
34
38
  end
35
39
  ```
36
40
 
41
+ When the errorgap browser SDK is on the page, its API calls send an
42
+ `x-errorgap-trace` header; the Rack middleware records it on the request's
43
+ transaction, so the browser's Performance view links each call to the server
44
+ request that answered it.
45
+
37
46
  ## Manual Notification
38
47
 
39
48
  ```ruby
@@ -70,6 +79,41 @@ Levels are `trace < debug < info < warn < error < fatal` (with aliases like
70
79
  `warning`/`critical`); anything below `config.minimum_log_level` (default
71
80
  `info`) is dropped locally. Set `config.logs_enabled = false` to disable.
72
81
 
82
+ ## Sign-ins
83
+
84
+ Report sign-ins to your app; errorgap shows them beside SSH logins in
85
+ Security › Logins, flags a new IP, country or hour for a user, and alerts on
86
+ a sign-in after many failures. Off until you opt in, since events carry user
87
+ names and IPs:
88
+
89
+ ```ruby
90
+ Errorgap.configure do |config|
91
+ config.auth_events = true
92
+ config.app_name = "oxcoffee-web" # default: the project slug
93
+ config.auth_user = :email # or :id, or ->(user) { ... }
94
+ end
95
+ ```
96
+
97
+ **Devise / Warden** apps need nothing else: the Railtie installs Warden hooks
98
+ that report each successful sign-in and each failed attempt (a POST that
99
+ Warden rejects; a locked account reports `locked`). Visiting a protected
100
+ page while signed out, expired sessions and remember-me cookies are not
101
+ reported. Outside Rails, call `Errorgap::WardenHooks.install` after
102
+ configuring.
103
+
104
+ Anything else, or other outcomes:
105
+
106
+ ```ruby
107
+ Errorgap.sign_in("success", user: user.email, request: request)
108
+ Errorgap.sign_in("mfa_failure", user: user.email, request: request)
109
+ Errorgap.sign_in("password_reset", user: user.email, request: request)
110
+ ```
111
+
112
+ Outcomes: `success`, `failure`, `password_reset`, `mfa_failure`, `locked`.
113
+ The request supplies the IP (`remote_ip`), user agent and path. Passwords
114
+ and tokens are never sent; errorgap can also store user names hashed
115
+ (Security › Logins › Web apps › Privacy).
116
+
73
117
  ## APM
74
118
 
75
119
  The Rack middleware records a web transaction per request automatically (with
@@ -16,7 +16,11 @@ module Errorgap
16
16
  :apm_sample_rate,
17
17
  :logs_enabled,
18
18
  :minimum_log_level,
19
- :max_breadcrumbs
19
+ :max_breadcrumbs,
20
+ :release,
21
+ :auth_events,
22
+ :auth_user,
23
+ :app_name
20
24
 
21
25
  def initialize
22
26
  @endpoint = ENV.fetch("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
@@ -33,6 +37,17 @@ module Errorgap
33
37
  @logs_enabled = true
34
38
  @minimum_log_level = "info"
35
39
  @max_breadcrumbs = 25
40
+ # The deployed version or commit: errors are attributed to the deploy
41
+ # that shipped them.
42
+ @release = ENV["ERRORGAP_RELEASE"].to_s.strip.empty? ? nil : ENV["ERRORGAP_RELEASE"].strip
43
+ # Sign-ins to this app (Security › Logins). Off until you opt in: they
44
+ # carry user names and IPs.
45
+ @auth_events = false
46
+ # How a signed-in user is named: a method (:email) or a proc. Default:
47
+ # email, username, login, then id.
48
+ @auth_user = nil
49
+ # The app's name in Security › Logins; defaults to the project slug.
50
+ @app_name = nil
36
51
  end
37
52
 
38
53
  def validate!
@@ -61,6 +61,7 @@ module Errorgap
61
61
  environment: @configuration.environment,
62
62
  root_directory: @configuration.root_directory
63
63
  }
64
+ context[:release] = @configuration.release if @configuration.release
64
65
  causes = collect_causes
65
66
  context[:causes] = causes unless causes.empty?
66
67
  context[:breadcrumbs] = @breadcrumbs unless @breadcrumbs.empty?
@@ -35,6 +35,15 @@ module Errorgap
35
35
  raise
36
36
  end
37
37
 
38
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
39
+
40
+ # The x-errorgap-trace header a browser SDK sent with this call, linking
41
+ # the browser's view of it to this transaction. Only a well-formed UUID.
42
+ def browser_trace_id(env)
43
+ value = env["HTTP_X_ERRORGAP_TRACE"].to_s.strip
44
+ value.match?(UUID) ? value.downcase : nil
45
+ end
46
+
38
47
  def apm_enabled?
39
48
  Errorgap.configuration.apm_enabled
40
49
  end
@@ -43,6 +52,7 @@ module Errorgap
43
52
  spans = SpanCollector.flush
44
53
  txn = Transaction.new(
45
54
  id: transaction_id,
55
+ trace_id: browser_trace_id(env),
46
56
  kind: "web",
47
57
  method: env["REQUEST_METHOD"],
48
58
  path: route_pattern(env),
@@ -25,6 +25,11 @@ module Errorgap
25
25
  SpanCollector.install
26
26
  end
27
27
 
28
+ # After config/initializers, so `auth_events` is readable here.
29
+ config.after_initialize do
30
+ Errorgap::WardenHooks.install if Errorgap.configuration.auth_events
31
+ end
32
+
28
33
  generators do
29
34
  require "generators/errorgap/install_generator"
30
35
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "time"
7
+
8
+ module Errorgap
9
+ # Sign-ins to this app, shown beside SSH logins in Security › Logins.
10
+ # Opt in with `config.auth_events = true`. Only who, from where and the
11
+ # result are sent: never passwords, tokens or session ids.
12
+ class SignIns
13
+ OUTCOMES = %w[success failure password_reset mfa_failure locked].freeze
14
+
15
+ def initialize(configuration)
16
+ configure(configuration)
17
+ end
18
+
19
+ def configure(configuration)
20
+ @configuration = configuration
21
+ end
22
+
23
+ # `request` (Rack or ActionDispatch) fills in the IP, user agent and path.
24
+ def record(outcome, user: nil, request: nil, ip: nil, user_agent: nil, path: nil, method: nil,
25
+ occurred_at: nil, sync: false)
26
+ return unless @configuration.auth_events
27
+ return if @configuration.ignored_environment?
28
+
29
+ outcome = outcome.to_s
30
+ unless OUTCOMES.include?(outcome)
31
+ @configuration.logger&.warn("[errorgap] unknown sign-in outcome #{outcome.inspect}")
32
+ return
33
+ end
34
+
35
+ if request
36
+ ip ||= request.respond_to?(:remote_ip) ? request.remote_ip : request.ip
37
+ user_agent ||= request.user_agent
38
+ path ||= "#{request.request_method} #{request.path}"
39
+ end
40
+ event = { occurred_at: (occurred_at || Time.now).utc.iso8601(3), outcome: outcome }
41
+ event[:user] = user.to_s if present?(user)
42
+ event[:ip] = ip.to_s if present?(ip)
43
+ event[:user_agent] = user_agent.to_s[0, 512] if present?(user_agent)
44
+ event[:path] = path.to_s[0, 200] if present?(path)
45
+ event[:method] = method.to_s if present?(method)
46
+ payload = {
47
+ app: @configuration.app_name || @configuration.project_slug,
48
+ environment: @configuration.environment,
49
+ sdk: "errorgap-ruby #{Errorgap::VERSION}",
50
+ events: [event]
51
+ }
52
+
53
+ if sync || !@configuration.async
54
+ deliver(payload)
55
+ else
56
+ Errorgap.register_thread(Thread.new { deliver(payload) })
57
+ nil
58
+ end
59
+ end
60
+
61
+ def deliver(payload)
62
+ uri = URI.join(
63
+ @configuration.endpoint.end_with?("/") ? @configuration.endpoint : "#{@configuration.endpoint}/",
64
+ "api/projects/#{@configuration.project_slug}/logins/web"
65
+ )
66
+ request = Net::HTTP::Post.new(uri)
67
+ request["Content-Type"] = "application/json"
68
+ request["User-Agent"] = "errorgap-ruby/#{Errorgap::VERSION}"
69
+ request["X-Errorgap-Project-Key"] = @configuration.api_key if present?(@configuration.api_key)
70
+ request.body = JSON.generate(payload)
71
+
72
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
73
+ http.request(request)
74
+ end
75
+ rescue StandardError => exception
76
+ @configuration.logger&.warn("[errorgap] sign-in delivery error: #{exception.class}: #{exception.message}")
77
+ end
78
+
79
+ private
80
+
81
+ def present?(value)
82
+ !value.nil? && !value.to_s.strip.empty?
83
+ end
84
+ end
85
+ end
@@ -17,7 +17,7 @@ module Errorgap
17
17
  end
18
18
 
19
19
  Transaction = Struct.new(
20
- :id, :kind, :method, :path, :path_raw, :status_code,
20
+ :id, :trace_id, :kind, :method, :path, :path_raw, :status_code,
21
21
  :duration_ms, :environment, :occurred_at, :spans,
22
22
  :job_class, :queue,
23
23
  keyword_init: true
@@ -25,6 +25,7 @@ module Errorgap
25
25
  def to_h
26
26
  {
27
27
  id: id,
28
+ trace_id: trace_id,
28
29
  kind: kind,
29
30
  method: method,
30
31
  path: path,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errorgap
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errorgap
4
+ # Devise and other Warden apps: report sign-ins without touching the
5
+ # controllers. The Rails railtie installs these when `auth_events` is on;
6
+ # elsewhere call `Errorgap::WardenHooks.install` after configuring.
7
+ module WardenHooks
8
+ # Failures that are not a sign-in attempt: visiting a protected page
9
+ # signed out, an expired session, an account that is not confirmed.
10
+ NOT_ATTEMPTS = %i[unauthenticated timeout inactive unconfirmed].freeze
11
+ # Form fields that hold the name being signed in with.
12
+ USER_PARAMS = %w[email login username].freeze
13
+
14
+ class << self
15
+ def install
16
+ return false unless defined?(::Warden::Manager)
17
+ return true if @installed
18
+
19
+ ::Warden::Manager.after_authentication do |user, auth, opts|
20
+ Errorgap::WardenHooks.on_success(user, auth, opts)
21
+ end
22
+ ::Warden::Manager.before_failure do |env, opts|
23
+ Errorgap::WardenHooks.on_failure(env, opts)
24
+ end
25
+ @installed = true
26
+ end
27
+
28
+ def on_success(user, auth, _opts = {})
29
+ # A remember-me cookie signing the user back in is not a new sign-in.
30
+ return if auth.respond_to?(:winning_strategy) && auth.winning_strategy.respond_to?(:key) &&
31
+ auth.winning_strategy.key == :rememberable
32
+
33
+ Errorgap.sign_in("success", user: identify(user), request: request_for(auth.env))
34
+ rescue StandardError => exception
35
+ Errorgap.configuration.logger&.warn("[errorgap] sign-in hook error: #{exception.class}: #{exception.message}")
36
+ end
37
+
38
+ def on_failure(env, opts = {})
39
+ message = opts[:message]&.to_sym
40
+ return if NOT_ATTEMPTS.include?(message)
41
+
42
+ request = request_for(env)
43
+ return unless request&.post?
44
+
45
+ outcome = message == :locked ? "locked" : "failure"
46
+ # Warden points PATH_INFO at the failure app before this runs; the
47
+ # form's path is in `attempted_path` (query string dropped).
48
+ path = opts[:attempted_path] ? "#{request.request_method} #{opts[:attempted_path].to_s.split('?').first}" : nil
49
+ Errorgap.sign_in(outcome, user: attempted_user(request, opts[:scope]), request: request, path: path)
50
+ rescue StandardError => exception
51
+ Errorgap.configuration.logger&.warn("[errorgap] sign-in hook error: #{exception.class}: #{exception.message}")
52
+ end
53
+
54
+ # How a signed-in user is named: `config.auth_user` (a method name or a
55
+ # proc), else email, username, login, then id.
56
+ def identify(user)
57
+ pick = Errorgap.configuration.auth_user
58
+ return pick.call(user) if pick.respond_to?(:call)
59
+ return user.public_send(pick) if pick && user.respond_to?(pick)
60
+
61
+ %i[email username login id].each do |name|
62
+ return user.public_send(name) if user.respond_to?(name)
63
+ end
64
+ nil
65
+ end
66
+
67
+ private
68
+
69
+ def attempted_user(request, scope)
70
+ params = request.params
71
+ fields = scope && params[scope.to_s].is_a?(Hash) ? params[scope.to_s] : params
72
+ USER_PARAMS.each do |name|
73
+ value = fields[name]
74
+ return value if value.is_a?(String) && !value.strip.empty?
75
+ end
76
+ nil
77
+ end
78
+
79
+ def request_for(env)
80
+ if defined?(::ActionDispatch::Request)
81
+ ::ActionDispatch::Request.new(env)
82
+ elsif defined?(::Rack::Request)
83
+ ::Rack::Request.new(env)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/errorgap.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "errorgap/breadcrumbs"
7
7
  require_relative "errorgap/notifier"
8
8
  require_relative "errorgap/notice"
9
9
  require_relative "errorgap/log_delivery"
10
+ require_relative "errorgap/sign_ins"
11
+ require_relative "errorgap/warden_hooks"
10
12
  require_relative "errorgap/transaction"
11
13
  require_relative "errorgap/span_collector"
12
14
  require_relative "errorgap/span_recorder"
@@ -21,6 +23,7 @@ module Errorgap
21
23
  notifier.configure(configuration)
22
24
  transacter.configure(configuration)
23
25
  log_delivery.configure(configuration)
26
+ sign_ins.configure(configuration)
24
27
  @breadcrumbs = Breadcrumbs.new(configuration.max_breadcrumbs)
25
28
  end
26
29
 
@@ -40,6 +43,19 @@ module Errorgap
40
43
  @log_delivery ||= LogDelivery.new(configuration)
41
44
  end
42
45
 
46
+ def sign_ins
47
+ @sign_ins ||= SignIns.new(configuration)
48
+ end
49
+
50
+ # Report a sign-in to this app: "success", "failure", "password_reset",
51
+ # "mfa_failure" or "locked". Pass the request for its IP, user agent and
52
+ # path. Needs `config.auth_events = true`; Devise apps get this
53
+ # automatically from the Warden hooks.
54
+ def sign_in(outcome, user: nil, request: nil, ip: nil, user_agent: nil, path: nil, method: nil, sync: false)
55
+ sign_ins.record(outcome, user: user, request: request, ip: ip, user_agent: user_agent,
56
+ path: path, method: method, sync: sync)
57
+ end
58
+
43
59
  def breadcrumbs
44
60
  @breadcrumbs ||= Breadcrumbs.new(configuration.max_breadcrumbs)
45
61
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errorgap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Errorgap
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -72,11 +72,13 @@ files:
72
72
  - lib/errorgap/notifier.rb
73
73
  - lib/errorgap/rack_middleware.rb
74
74
  - lib/errorgap/rails/railtie.rb
75
+ - lib/errorgap/sign_ins.rb
75
76
  - lib/errorgap/span_collector.rb
76
77
  - lib/errorgap/span_recorder.rb
77
78
  - lib/errorgap/transacter.rb
78
79
  - lib/errorgap/transaction.rb
79
80
  - lib/errorgap/version.rb
81
+ - lib/errorgap/warden_hooks.rb
80
82
  - lib/generators/errorgap/install_generator.rb
81
83
  homepage: https://github.com/errorgaphq/errorgap-ruby
82
84
  licenses: