Class: WebhookLog
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- WebhookLog
- Defined in:
- app/models/webhook_log.rb
Overview
== Schema Information
Table name: webhook_logs
Database name: primary
id :bigint not null, primary key
category :string not null
data :jsonb not null
next_attempt :datetime
notes :text
process_attempts :integer default(0), not null
processed_at :datetime
provider :string not null
resource_type :string
response_data :jsonb
state :string default("ready"), not null
created_at :datetime not null
updated_at :datetime not null
external_id :string
resource_id :integer
Indexes
idx_webhook_logs_provider_category_state (provider,category,state)
idx_webhook_logs_resource (resource_type,resource_id)
idx_webhook_logs_state_created (state,created_at)
index_webhook_logs_on_category (category)
index_webhook_logs_on_external_id (external_id)
index_webhook_logs_on_next_attempt (next_attempt) USING brin
Constant Summary collapse
- PROVIDERS =
Providers that can send webhooks
%w[amazon_sp_api assemblyai curri freightquote oxylabs sendgrid sftpgo shipengine switchvox].freeze
- CATEGORIES =
Categories by provider
{ # SP-API notification types (downcased), drained from SQS by # AmazonSqsNotificationPollerWorker rather than received over HTTP. # See doc/tasks/202607152125_AMAZON_SPAPI_SQS_NOTIFICATIONS.md 'amazon_sp_api' => %w[ account_status_changed any_offer_changed b2b_any_offer_changed branded_item_content_change detail_page_traffic_event external_fulfillment_shipment_status_change fba_inventory_availability_changes fba_outbound_shipment_status fee_promotion feed_processing_finished fulfillment_order_status item_inventory_event_change item_product_type_change item_sales_event_change listings_item_issues_change listings_item_mfn_quantity_change listings_item_status_change order_change pricing_health product_type_definitions_change report_processing_finished transaction_update unknown ], 'assemblyai' => %w[transcription_complete], 'curri' => %w[delivery_update], 'freightquote' => %w[ load_created load_booked load_cancelled load_picked_up load_delivered order_created order_updated order_rejected order_canceled order_completed appointment_updated carrier_arrived carrier_departed in_transit in_transit_to_origin pro_number_added chr_tracking_number_published unknown ], 'oxylabs' => %w[price_check price_check_complete], 'sendgrid' => %w[delivery bounce engagement suppression unknown], 'sftpgo' => %w[recording_uploaded], 'shipengine' => %w[tracking_update], 'switchvox' => %w[new_voicemail checked_voicemail incoming_call route_to_extension call_answered call_hangup outgoing_call agent_login agent_logout unknown] }.freeze
- MAX_RETRY_ATTEMPTS =
Maximum retry attempts before moving to exception
5- RETRY_DELAYS =
Retry delay calculation (exponential backoff)
[5.minutes, 15.minutes, 1.hour, 4.hours, 24.hours].freeze
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
- #category ⇒ String readonly
- #data ⇒ Hash readonly
- #provider ⇒ String readonly
Class Method Summary collapse
-
.awaiting_callback ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are awaiting callback.
-
.create_pending!(provider:, category:, resource_type:, resource_id:, external_id: nil, data: {}, notes: nil) ⇒ WebhookLog
Class method to create a pending entry when submitting a job Call this when you submit a job that expects a webhook callback.
-
.find_by_external_id(provider, external_id) ⇒ void
Find any entry by external_id (for duplicate detection) Uses the external_id column, not JSON data.
-
.find_pending_for_resource(provider, category, resource_type, resource_id) ⇒ void
Find a pending entry for a specific resource.
-
.for_resource ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are for resource.
-
.ingest!(provider:, category:, data:, resource_type: nil, resource_id: nil, external_id: nil, notes: nil) ⇒ WebhookLog
Class method to ingest a webhook callback Finds existing pending entry or creates a new ready entry Prevents duplicates by checking external_id.
-
.payload_contains ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are payload contains.
-
.providers_for_select ⇒ Object
Helper for tom-select dropdowns.
- .ransackable_associations(_auth_object = nil) ⇒ void
-
.ransackable_attributes(_auth_object = nil) ⇒ void
Ransack attributes for search.
-
.ransackable_scopes(_auth_object = nil) ⇒ void
Custom Ransack scopes for advanced searching.
-
.recent ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are recent.
-
.requiring_processing ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are requiring processing.
-
.stale_pending ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are stale pending.
-
.states_for_select ⇒ Object
Helper for tom-select dropdowns.
Instance Method Summary collapse
-
#data_json ⇒ Object
Virtual attribute for editing data as JSON string Used in the edit form to allow manual payload editing.
- #data_json=(json_string) ⇒ void
-
#display_name ⇒ Object
Display name for logs.
-
#human_state_name ⇒ Object
Human readable state name.
-
#process! ⇒ Object
Process this webhook log entry Delegates to the appropriate processor based on provider/category.
-
#processor_class ⇒ Object
Get the appropriate processor class for this webhook.
-
#resource ⇒ Object
Find the associated resource.
Methods inherited from ApplicationRecord
ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#category ⇒ String (readonly)
78 |
# File 'app/models/webhook_log.rb', line 78 validates :category, presence: true |
#data ⇒ Hash (readonly)
80 |
# File 'app/models/webhook_log.rb', line 80 validates :data, presence: true |
#provider ⇒ String (readonly)
76 |
# File 'app/models/webhook_log.rb', line 76 validates :provider, presence: true |
Class Method Details
.awaiting_callback ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are awaiting callback. Active Record Scope
130 |
# File 'app/models/webhook_log.rb', line 130 scope :awaiting_callback, -> { where(state: 'pending') } |
.create_pending!(provider:, category:, resource_type:, resource_id:, external_id: nil, data: {}, notes: nil) ⇒ WebhookLog
Class method to create a pending entry when submitting a job
Call this when you submit a job that expects a webhook callback
248 249 250 251 252 253 254 255 256 257 258 259 |
# File 'app/models/webhook_log.rb', line 248 def self.create_pending!(provider:, category:, resource_type:, resource_id:, external_id: nil, data: {}, notes: nil) create!( provider: provider, category: category, resource_type: resource_type, resource_id: resource_id, external_id: external_id, data: data, state: 'pending', notes: notes ) end |
.find_by_external_id(provider, external_id) ⇒ void
This method returns an undefined value.
Find any entry by external_id (for duplicate detection)
Uses the external_id column, not JSON data
359 360 361 362 363 364 365 |
# File 'app/models/webhook_log.rb', line 359 def self.find_by_external_id(provider, external_id) return nil if external_id.blank? where(provider: provider, external_id: external_id) .order(created_at: :desc) .first end |
.find_pending_for_resource(provider, category, resource_type, resource_id) ⇒ void
This method returns an undefined value.
Find a pending entry for a specific resource
373 374 375 376 377 378 379 380 381 382 383 |
# File 'app/models/webhook_log.rb', line 373 def self.find_pending_for_resource(provider, category, resource_type, resource_id) return nil if resource_type.blank? || resource_id.blank? where( provider: provider, category: category, resource_type: resource_type, resource_id: resource_id, state: 'pending' ).order(created_at: :desc).first end |
.for_resource ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are for resource. Active Record Scope
132 |
# File 'app/models/webhook_log.rb', line 132 scope :for_resource, ->(resource) { where(resource_type: resource.class.name, resource_id: resource.id) } |
.ingest!(provider:, category:, data:, resource_type: nil, resource_id: nil, external_id: nil, notes: nil) ⇒ WebhookLog
Class method to ingest a webhook callback
Finds existing pending entry or creates a new ready entry
Prevents duplicates by checking external_id
272 273 274 275 276 277 278 279 280 281 |
# File 'app/models/webhook_log.rb', line 272 def self.ingest!(provider:, category:, data:, resource_type: nil, resource_id: nil, external_id: nil, notes: nil) log = resolve_ingest(provider:, category:, data:, resource_type:, resource_id:, external_id:, notes:) # Confirm the row durably committed before the controller ACKs 200 to the # provider. A silent fake-success commit (see Durability) would otherwise # drop the webhook with no retry and no trace; raising here makes the # controller return 5xx so the provider re-delivers. Verify by natural key # when we have an external_id (the high-value providers all send one). Durability.confirm_persisted!(self, { provider: provider, external_id: external_id }, context: { category: category }) if external_id.present? log end |
.payload_contains ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are payload contains. Active Record Scope
138 139 140 141 142 143 |
# File 'app/models/webhook_log.rb', line 138 scope :payload_contains, ->(term) { return none if term.blank? sanitized = "%#{sanitize_sql_like(term)}%" where('data::text ILIKE :term OR response_data::text ILIKE :term', term: sanitized) } |
.providers_for_select ⇒ Object
Helper for tom-select dropdowns
449 450 451 |
# File 'app/models/webhook_log.rb', line 449 def self.providers_for_select PROVIDERS.map { |p| [p.titleize, p] } end |
.ransackable_associations(_auth_object = nil) ⇒ void
157 158 159 |
# File 'app/models/webhook_log.rb', line 157 def self.ransackable_associations(_auth_object = nil) [] end |
.ransackable_attributes(_auth_object = nil) ⇒ void
This method returns an undefined value.
Ransack attributes for search
148 149 150 151 |
# File 'app/models/webhook_log.rb', line 148 def self.ransackable_attributes(_auth_object = nil) %w[id provider category resource_type resource_id external_id state process_attempts created_at updated_at processed_at next_attempt notes] end |
.ransackable_scopes(_auth_object = nil) ⇒ void
This method returns an undefined value.
Custom Ransack scopes for advanced searching
164 165 166 |
# File 'app/models/webhook_log.rb', line 164 def self.ransackable_scopes(_auth_object = nil) %i[payload_contains] end |
.recent ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are recent. Active Record Scope
134 |
# File 'app/models/webhook_log.rb', line 134 scope :recent, ->(hours = 24) { where(created_at: hours.hours.ago..) } |
.requiring_processing ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are requiring processing. Active Record Scope
116 117 118 119 120 |
# File 'app/models/webhook_log.rb', line 116 scope :requiring_processing, -> { where(state: 'ready') .or(where(state: 'retry').where(next_attempt: ..Time.current)) .order(:created_at) } |
.stale_pending ⇒ ActiveRecord::Relation<WebhookLog>
A relation of WebhookLogs that are stale pending. Active Record Scope
123 124 125 126 127 |
# File 'app/models/webhook_log.rb', line 123 scope :stale_pending, ->(threshold = 1.hour) { where(state: 'pending') .where(created_at: ..threshold.ago) .order(:created_at) } |
.states_for_select ⇒ Object
Helper for tom-select dropdowns
444 445 446 |
# File 'app/models/webhook_log.rb', line 444 def self.states_for_select state_machine.states.map { |s| [s.name.to_s.titleize, s.name.to_s] } end |
Instance Method Details
#data_json ⇒ Object
Virtual attribute for editing data as JSON string
Used in the edit form to allow manual payload editing
85 86 87 88 89 |
# File 'app/models/webhook_log.rb', line 85 def data_json JSON.pretty_generate(data) if data.present? rescue JSON::GeneratorError data.to_s end |
#data_json=(json_string) ⇒ void
95 96 97 98 99 100 101 102 103 |
# File 'app/models/webhook_log.rb', line 95 def data_json=(json_string) return if json_string.blank? self.data = JSON.parse(json_string) rescue JSON::ParserError => e @data_json_error = e. # Keep the raw string so we can show an error and preserve user input @data_json_raw = json_string end |
#display_name ⇒ Object
Display name for logs
459 460 461 |
# File 'app/models/webhook_log.rb', line 459 def display_name "#{provider.titleize} - #{category} (##{id})" end |
#human_state_name ⇒ Object
Human readable state name
454 455 456 |
# File 'app/models/webhook_log.rb', line 454 def human_state_name state.to_s.titleize end |
#process! ⇒ Object
Process this webhook log entry
Delegates to the appropriate processor based on provider/category
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
# File 'app/models/webhook_log.rb', line 387 def process! return unless can_start_processing? start_processing! begin result = processor_class.call(self) self.response_data = result if result.is_a?(Hash) self.notes = nil # Clear any previous error notes on success complete! rescue StandardError => e self.notes = "#{e.class}: #{e.}\n#{e.backtrace&.first(5)&.join("\n")}" if process_attempts >= MAX_RETRY_ATTEMPTS fail! ErrorReporting.error(e, webhook_log_id: id, provider: provider, category: category) else schedule_retry! Rails.logger.warn "[WebhookLog] Scheduled retry for #{id}: #{e.}" end end end |
#processor_class ⇒ Object
Get the appropriate processor class for this webhook
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 |
# File 'app/models/webhook_log.rb', line 411 def processor_class case provider when 'amazon_sp_api' WebhookProcessors::AmazonSpApiProcessor when 'assemblyai' WebhookProcessors::AssemblyaiProcessor when 'curri' WebhookProcessors::CurriProcessor when 'freightquote' WebhookProcessors::FreightquoteProcessor when 'oxylabs' WebhookProcessors::OxylabsProcessor when 'sendgrid' WebhookProcessors::SendgridProcessor when 'sftpgo' WebhookProcessors::SftpgoProcessor when 'shipengine' WebhookProcessors::ShipengineProcessor when 'switchvox' WebhookProcessors::SwitchvoxProcessor else raise NotImplementedError, "No processor for provider: #{provider}" end end |
#resource ⇒ Object
Find the associated resource
437 438 439 440 441 |
# File 'app/models/webhook_log.rb', line 437 def resource return nil if resource_type.blank? || resource_id.blank? resource_type.constantize.find_by(id: resource_id) end |