chronicle-rails 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +6 -0
  5. data/app/controllers/chronicle/api_logs_controller.rb +61 -0
  6. data/app/controllers/chronicle/api_routes_controller.rb +35 -0
  7. data/app/controllers/chronicle/application_controller.rb +23 -0
  8. data/app/controllers/chronicle/auth_controller.rb +17 -0
  9. data/app/controllers/chronicle/error_groups_controller.rb +58 -0
  10. data/app/controllers/chronicle/error_logs_controller.rb +47 -0
  11. data/app/controllers/chronicle/resource_controller.rb +41 -0
  12. data/app/controllers/concerns/chronicle/filterable.rb +57 -0
  13. data/app/controllers/concerns/chronicle/pagination.rb +41 -0
  14. data/app/errors/chronicle/authentication_error.rb +7 -0
  15. data/app/errors/chronicle/bad_request_error.rb +7 -0
  16. data/app/errors/chronicle/base_error.rb +10 -0
  17. data/app/errors/chronicle/forbidden_error.rb +7 -0
  18. data/app/errors/chronicle/not_acceptable_error.rb +7 -0
  19. data/app/errors/chronicle/not_found_error.rb +7 -0
  20. data/app/errors/chronicle/resource_busy_error.rb +7 -0
  21. data/app/errors/chronicle/validation_error.rb +7 -0
  22. data/app/jobs/chronicle/application_job.rb +4 -0
  23. data/app/jobs/chronicle/flush_api_logs_job.rb +9 -0
  24. data/app/mailers/chronicle/application_mailer.rb +6 -0
  25. data/app/models/chronicle/admin_user.rb +16 -0
  26. data/app/models/chronicle/api_log.rb +25 -0
  27. data/app/models/chronicle/api_route.rb +5 -0
  28. data/app/models/chronicle/application_record.rb +5 -0
  29. data/app/models/chronicle/error_group.rb +53 -0
  30. data/app/models/chronicle/error_log.rb +69 -0
  31. data/app/services/chronicle/api_logs/buffer.rb +62 -0
  32. data/app/services/chronicle/api_logs/flusher.rb +98 -0
  33. data/app/services/chronicle/api_logs/metrics.rb +285 -0
  34. data/app/services/chronicle/api_logs/updater.rb +19 -0
  35. data/app/services/chronicle/api_routes/stats.rb +131 -0
  36. data/app/services/chronicle/error_logs/group_resolver.rb +66 -0
  37. data/config/routes.rb +28 -0
  38. data/db/migrate/20260101000001_create_chronicle_admin_users.rb +16 -0
  39. data/db/migrate/20260101000002_create_chronicle_api_logs.rb +32 -0
  40. data/db/migrate/20260101000003_create_chronicle_api_routes.rb +13 -0
  41. data/db/migrate/20260101000004_create_chronicle_error_groups.rb +26 -0
  42. data/db/migrate/20260101000005_create_chronicle_error_logs.rb +19 -0
  43. data/lib/chronicle/configuration.rb +56 -0
  44. data/lib/chronicle/engine.rb +12 -0
  45. data/lib/chronicle/util.rb +26 -0
  46. data/lib/chronicle/version.rb +3 -0
  47. data/lib/chronicle-rails.rb +1 -0
  48. data/lib/chronicle.rb +70 -0
  49. data/lib/tasks/chronicle_tasks.rake +4 -0
  50. metadata +127 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01df3cacea5d88d244620c36366cb20ddfd054c6ea59ee4894bb3e1fbd5f25e5
4
+ data.tar.gz: 5029310077b4c2e31e5da5bbea76881805ad601858586067ccc4dfed50718e9e
5
+ SHA512:
6
+ metadata.gz: ace523c09d4cd700f40f58656bde01c84ee0f750c21a256c6aa376fb124e9547d1dbd1a41e103d577ed8cab8b32fbffd886203e113e5ffb001a288da6f5e46f1
7
+ data.tar.gz: a2e3007b8a1d5e6cfc4621acf9b42fd1bee6d1f1efba4bfc3f958ca78aacaeee6102cec904e05ec732e3f4db93d5889c791961b12dffd01ec85a8c92566f2662
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Chronicle
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "chronicle"
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install chronicle
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/setup'
2
+
3
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
4
+ load 'rails/tasks/engine.rake'
5
+
6
+ require 'bundler/gem_tasks'
@@ -0,0 +1,61 @@
1
+ module Chronicle
2
+ class ApiLogsController < ResourceController
3
+ FILTER_DEFINITION = {
4
+ user_id: :exact,
5
+ request_id: :exact,
6
+ device_os: :exact,
7
+ device_id: :exact,
8
+ device_type: :exact,
9
+ ip_address: :exact,
10
+ http_method: :exact,
11
+ api_endpoint: :like,
12
+ http_status_code: :exact,
13
+ brand: :exact,
14
+ device_model_name: :like,
15
+ os_version: :exact,
16
+ backend_version: :exact,
17
+ client_version: :exact,
18
+ time_zone: :exact,
19
+ start_date: :date_range,
20
+ end_date: :date_range,
21
+ }.freeze
22
+
23
+ def update
24
+ api_log = ApiLogs::Updater.new(params[:request_id], params.require(:frontend_response_time_ms)).call
25
+ render json: { status: 'success', id: api_log.id }, status: :ok
26
+ end
27
+
28
+ def kpi_cards
29
+ render json: ApiLogs::Metrics.kpi_cards(filters: metrics_filters), status: :ok
30
+ end
31
+
32
+ def distribution_metrics
33
+ render json: ApiLogs::Metrics.distribution_metrics(filters: metrics_filters), status: :ok
34
+ end
35
+
36
+ private
37
+
38
+ def model
39
+ ApiLog
40
+ end
41
+
42
+ def filter_definition
43
+ FILTER_DEFINITION
44
+ end
45
+
46
+ def date_column
47
+ :timestamp
48
+ end
49
+
50
+ def eager_load_associations
51
+ []
52
+ end
53
+
54
+ def metrics_filters
55
+ params.fetch(:filters, {}).permit(
56
+ :start_date, :end_date, :client_version, :backend_version,
57
+ :device_os, :time_zone, :api_endpoint, :http_method
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,35 @@
1
+ module Chronicle
2
+ class ApiRoutesController < ResourceController
3
+ FILTER_DEFINITION = {
4
+ http_method: :exact,
5
+ path: :like,
6
+ }.freeze
7
+
8
+ before_action :authenticate_admin_user!
9
+
10
+ def stats
11
+ result = ApiRoutes::Stats.new(
12
+ filters: stats_filters,
13
+ sort_by: params[:sort_by],
14
+ sort_direction: params[:sort_direction],
15
+ page: params[:page],
16
+ per_page: params[:per_page]
17
+ ).call
18
+ render json: result, status: :ok
19
+ end
20
+
21
+ private
22
+
23
+ def model
24
+ ApiRoute
25
+ end
26
+
27
+ def filter_definition
28
+ FILTER_DEFINITION
29
+ end
30
+
31
+ def stats_filters
32
+ params.fetch(:filters, {}).permit(:start_date, :end_date, :http_method, :api_endpoint)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ module Chronicle
2
+ class ApplicationController < ActionController::API
3
+ rescue_from BaseError do |e|
4
+ render json: { error: e.message }, status: e.status_code
5
+ end
6
+
7
+ rescue_from ActionController::ParameterMissing do |e|
8
+ render json: { error: e.message }, status: :bad_request
9
+ end
10
+
11
+ private
12
+
13
+ def authenticate_admin_user!
14
+ auth_token = request.headers['X-Admin-Auth-Token']
15
+ raise ForbiddenError, 'Missing admin auth token' unless auth_token
16
+
17
+ admin_user = AdminUser.find_by(auth_token: auth_token)
18
+ raise ForbiddenError, 'Invalid admin auth token' unless admin_user
19
+
20
+ @current_admin_user = admin_user
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ module Chronicle
2
+ class AuthController < ApplicationController
3
+ def login
4
+ email = params.require(:email)
5
+ password = params.require(:password)
6
+
7
+ admin_user = AdminUser.find_by(email: email)
8
+ raise NotFoundError, 'User not found' unless admin_user
9
+
10
+ if admin_user.authenticate(password)
11
+ render json: { admin_user: admin_user.slice(:id, :name, :email, :auth_token) }, status: :ok
12
+ else
13
+ render json: { error: 'Invalid email or password' }, status: :unauthorized
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ module Chronicle
2
+ class ErrorGroupsController < ResourceController
3
+ before_action :authenticate_admin_user!, only: [:index, :show, :update] # rubocop:disable Rails/LexicallyScopedActionFilter
4
+ before_action :set_error_group, only: [:update]
5
+
6
+ FILTER_DEFINITION = {
7
+ project: :exact,
8
+ source_type: :exact,
9
+ source_name: :like,
10
+ error_message: :like,
11
+ status: :exact,
12
+ fingerprint: :exact,
13
+ backend_version: :exact,
14
+ client_version: :exact,
15
+ start_date: :date_range,
16
+ end_date: :date_range,
17
+ }.freeze
18
+
19
+ def show
20
+ record = ErrorGroup.find_by(id: params[:id])
21
+ raise NotFoundError, 'Error group not found' unless record
22
+
23
+ render json: record.get_hash, status: :ok
24
+ end
25
+
26
+ def update
27
+ if params[:status].present? && ErrorGroup::VALID_STATUSES.exclude?(params[:status])
28
+ raise BadRequestError, 'Invalid status'
29
+ end
30
+
31
+ @error_group.update!(update_params)
32
+ render json: @error_group.get_hash, status: :ok
33
+ end
34
+
35
+ private
36
+
37
+ def model
38
+ ErrorGroup
39
+ end
40
+
41
+ def filter_definition
42
+ FILTER_DEFINITION
43
+ end
44
+
45
+ def date_column
46
+ :last_seen_at
47
+ end
48
+
49
+ def set_error_group
50
+ @error_group = ErrorGroup.find_by(id: params[:id])
51
+ raise NotFoundError, 'Error group not found' unless @error_group
52
+ end
53
+
54
+ def update_params
55
+ params.permit(:status, :jira_link)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,47 @@
1
+ module Chronicle
2
+ class ErrorLogsController < ResourceController
3
+ before_action :authenticate_admin_user!, only: [:index, :show, :destroy] # rubocop:disable Rails/LexicallyScopedActionFilter
4
+ before_action :set_error_log, only: [:destroy]
5
+
6
+ FILTER_DEFINITION = {
7
+ request_id: :exact,
8
+ backend_version: :exact,
9
+ client_version: :exact,
10
+ user_id: :exact,
11
+ error_group_id: :exact,
12
+ start_date: :date_range,
13
+ end_date: :date_range,
14
+ }.freeze
15
+
16
+ def show
17
+ record = ErrorLog.find_by(id: params[:id])
18
+ raise NotFoundError, 'Error log not found' unless record
19
+
20
+ render json: record.get_hash, status: :ok
21
+ end
22
+
23
+ def destroy
24
+ @error_log.destroy
25
+ render status: :no_content
26
+ end
27
+
28
+ private
29
+
30
+ def model
31
+ ErrorLog
32
+ end
33
+
34
+ def filter_definition
35
+ FILTER_DEFINITION
36
+ end
37
+
38
+ def set_error_log
39
+ @error_log = ErrorLog.find_by(id: params[:id])
40
+ raise NotFoundError, 'Error log not found' unless @error_log
41
+ end
42
+
43
+ def eager_load_associations
44
+ [:error_group]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,41 @@
1
+ module Chronicle
2
+ class ResourceController < ApplicationController
3
+ include Pagination
4
+ include Filterable
5
+
6
+ before_action :authenticate_admin_user!, only: [:index, :show]
7
+
8
+ def index
9
+ query = build_query(model, filter_definition: filter_definition, filters: params[:filters],
10
+ date_column: date_column)
11
+ query = query.includes(eager_load_associations) if eager_load_associations.any?
12
+ render json: paginate(query), status: :ok
13
+ end
14
+
15
+ def show
16
+ record = model.find_by(id: params[:id])
17
+ raise NotFoundError, 'Record not found' unless record
18
+
19
+ data = record.respond_to?(:get_hash) ? record.get_hash : record.attributes
20
+ render json: data, status: :ok
21
+ end
22
+
23
+ private
24
+
25
+ def model
26
+ raise NotImplementedError, "#{self.class} must define #model"
27
+ end
28
+
29
+ def filter_definition
30
+ raise NotImplementedError, "#{self.class} must define #filter_definition"
31
+ end
32
+
33
+ def date_column
34
+ :created_at
35
+ end
36
+
37
+ def eager_load_associations
38
+ []
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ module Chronicle
2
+ module Filterable
3
+ extend ActiveSupport::Concern
4
+
5
+ def build_query(model, filter_definition: {}, filters: {}, group_by: nil, date_column: :created_at)
6
+ scope = model.all
7
+ params_to_hash = filters.present? ? Util.coerce_to_hash(filters).symbolize_keys : {}
8
+
9
+ filter_definition.each do |key, filter_type|
10
+ param_value = params_to_hash[key]
11
+ next if param_value.blank?
12
+
13
+ scope = apply_filter(scope, key, filter_type, param_value, date_column)
14
+ end
15
+
16
+ scope = scope.group(group_by) if group_by.present?
17
+
18
+ scope
19
+ end
20
+
21
+ private
22
+
23
+ def apply_filter(scope, key, filter_type, value, date_column)
24
+ case filter_type
25
+ when :like
26
+ sanitized_value = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
27
+ scope.where(scope.arel_table[key].matches(sanitized_value))
28
+ when :date_range
29
+ apply_date_range_filter(scope, key, value, date_column)
30
+ when :exact
31
+ scope.where(key => value)
32
+ end
33
+ end
34
+
35
+ def apply_date_range_filter(scope, key, value, date_column)
36
+ date = Util.parse_date(value)
37
+ raise BadRequestError, 'Invalid Date Format' if date.nil?
38
+
39
+ case key
40
+ when :start_date
41
+ apply_date_range(scope, { from: date.beginning_of_day }, date_key: date_column)
42
+ when :end_date
43
+ apply_date_range(scope, { to: date.end_of_day }, date_key: date_column)
44
+ else
45
+ scope.where(date_column => date.all_day)
46
+ end
47
+ end
48
+
49
+ def apply_date_range(scope, range, date_key: :datetime)
50
+ return scope if range.nil?
51
+
52
+ scope = scope.where(date_key => (range[:from])..) if range[:from].present?
53
+ scope = scope.where(date_key => ..(range[:to])) if range[:to].present?
54
+ scope
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ module Chronicle
2
+ module Pagination
3
+ extend ActiveSupport::Concern
4
+
5
+ DEFAULT_LIMIT = 20
6
+ MAX_LIMIT = 100
7
+
8
+ def paginate(scope)
9
+ limit = validate_limit
10
+ last_id = params[:last_id]&.to_i
11
+
12
+ scope = scope.where(id: ...last_id) if last_id.present?
13
+ scope = scope.order(id: order_key).limit(limit)
14
+ batch_count = scope.size
15
+
16
+ {
17
+ data: scope.map { |record| item_data(record) },
18
+ pagination: {
19
+ has_more: batch_count == limit,
20
+ last_id: scope.last&.id,
21
+ batch_count: batch_count,
22
+ },
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def validate_limit
29
+ limit = params[:limit].present? ? [params[:limit].to_i, 1].max : DEFAULT_LIMIT
30
+ [limit, MAX_LIMIT].min
31
+ end
32
+
33
+ def order_key
34
+ params[:order_by] == 'asc' ? :asc : :desc
35
+ end
36
+
37
+ def item_data(record)
38
+ record.respond_to?(:get_hash) ? record.get_hash : record.attributes
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class AuthenticationError < BaseError
3
+ def initialize(message = 'Request not acceptable')
4
+ super(message, 401)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class BadRequestError < BaseError
3
+ def initialize(message = 'Bad Request')
4
+ super(message, 400)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ module Chronicle
2
+ class BaseError < StandardError
3
+ attr_reader :status_code
4
+
5
+ def initialize(message = nil, status_code = 400)
6
+ super(message)
7
+ @status_code = status_code
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class ForbiddenError < BaseError
3
+ def initialize(message = 'Unauthorized')
4
+ super(message, 403)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class NotAcceptableError < BaseError
3
+ def initialize(message = 'Request not acceptable')
4
+ super(message, 406)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class NotFoundError < BaseError
3
+ def initialize(message = 'Resource not found')
4
+ super(message, 404)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class ResourceBusyError < BaseError
3
+ def initialize(message = 'Resource busy')
4
+ super(message, 409)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Chronicle
2
+ class ValidationError < BaseError
3
+ def initialize(message = 'Validation failed')
4
+ super(message, 422)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ module Chronicle
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ module Chronicle
2
+ class FlushApiLogsJob < ApplicationJob
3
+ queue_as :low
4
+
5
+ def perform
6
+ Chronicle.flush_api_logs!
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module Chronicle
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ module Chronicle
2
+ class AdminUser < ApplicationRecord
3
+ enum :role, a1: 0, a2: 1
4
+ validates :password, presence: true, length: { minimum: 6 }
5
+ validates :email, :auth_token, :name, presence: true
6
+
7
+ has_secure_password validations: false
8
+
9
+ A1 = :a1
10
+ A2 = :a2
11
+
12
+ def basic_info
13
+ self.slice(:id, :name, :email)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ module Chronicle
2
+ class ApiLog < ApplicationRecord
3
+ after_commit :sync_api_route, on: :create
4
+
5
+ def get_hash
6
+ hash = attributes
7
+
8
+ klass = Chronicle.config.user_model
9
+
10
+ hash['user'] = klass.find_by(id: user_id)&.basic_info if user_id.present? && klass.present?
11
+
12
+ hash
13
+ end
14
+
15
+ private
16
+
17
+ def sync_api_route
18
+ return if api_endpoint.blank? || http_method.blank?
19
+ return if ApiRoute.exists?(path: api_endpoint, http_method: http_method)
20
+
21
+ ApiRoute.create!(path: api_endpoint, http_method: http_method, first_seen_at: created_at,
22
+ created_at: created_at, updated_at: created_at)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ module Chronicle
2
+ class ApiRoute < ApplicationRecord
3
+ validates :path, :http_method, presence: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Chronicle
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,53 @@
1
+ module Chronicle
2
+ class ErrorGroup < ApplicationRecord
3
+ has_many :error_logs, dependent: :destroy
4
+
5
+ OPEN = 'open'.freeze
6
+ RESOLVED = 'resolved'.freeze
7
+ IGNORED = 'ignored'.freeze
8
+ VALID_STATUSES = [OPEN, RESOLVED, IGNORED].freeze
9
+
10
+ validates :project, presence: true
11
+ validates :fingerprint, presence: true
12
+ validates :source_type,
13
+ :source_name,
14
+ :error_message, presence: true
15
+ validates :status, inclusion: { in: VALID_STATUSES }, presence: true
16
+ validates :first_seen_at,
17
+ :last_seen_at, presence: true
18
+ validates :occurrence_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
19
+
20
+ # Derives a deterministic fingerprint from the structural identity of an error.
21
+ # Same source location + message + cleaned backtrace always yields the same hash,
22
+ # so two occurrences of the same bug map to the same group.
23
+ def self.compute_fingerprint(error_log)
24
+ payload = [
25
+ error_log.source_type,
26
+ error_log.source_name,
27
+ error_log.error_message,
28
+ error_log.cleaned_backtrace.to_s.first(2000),
29
+ ].join("\xff")
30
+ Digest::SHA256.hexdigest(payload)
31
+ end
32
+
33
+ # Atomically records a new occurrence of this error.
34
+ # Uses a row-level lock so concurrent increments never collide.
35
+ def record_occurrence!(backend_version: nil, client_version: nil, at: Time.current)
36
+ with_lock do
37
+ self.last_seen_at = [last_seen_at, at].compact.max
38
+ self.backend_version = backend_version if backend_version.present?
39
+ self.client_version = client_version if client_version.present?
40
+ self.occurrence_count += 1
41
+ save!
42
+ end
43
+ end
44
+
45
+ def get_hash
46
+ attributes.symbolize_keys.merge(error_fingerprint: fingerprint)
47
+ end
48
+
49
+ def as_json(_options = {})
50
+ get_hash
51
+ end
52
+ end
53
+ end