webhookdb 1.4.0 → 1.5.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/db/migrations/026_undo_integration_backfill_cursor.rb +2 -0
  3. data/db/migrations/032_remove_db_defaults.rb +2 -0
  4. data/db/migrations/043_text_search.rb +2 -0
  5. data/db/migrations/047_sync_parallelism.rb +9 -0
  6. data/db/migrations/048_sync_stats.rb +9 -0
  7. data/db/migrations/049_error_handlers.rb +18 -0
  8. data/db/migrations/050_logged_webhook_indices.rb +25 -0
  9. data/db/migrations/051_partitioning.rb +9 -0
  10. data/integration/async_spec.rb +0 -2
  11. data/integration/service_integrations_spec.rb +0 -2
  12. data/lib/amigo/durable_job.rb +2 -2
  13. data/lib/amigo/job_in_context.rb +12 -0
  14. data/lib/webhookdb/api/entities.rb +6 -2
  15. data/lib/webhookdb/api/error_handlers.rb +104 -0
  16. data/lib/webhookdb/api/helpers.rb +8 -1
  17. data/lib/webhookdb/api/icalproxy.rb +22 -0
  18. data/lib/webhookdb/api/install.rb +2 -1
  19. data/lib/webhookdb/api/saved_queries.rb +1 -0
  20. data/lib/webhookdb/api/saved_views.rb +1 -0
  21. data/lib/webhookdb/api/service_integrations.rb +1 -1
  22. data/lib/webhookdb/api/sync_targets.rb +1 -1
  23. data/lib/webhookdb/api/system.rb +5 -0
  24. data/lib/webhookdb/api/webhook_subscriptions.rb +1 -0
  25. data/lib/webhookdb/api.rb +4 -1
  26. data/lib/webhookdb/apps.rb +4 -0
  27. data/lib/webhookdb/async/autoscaler.rb +10 -0
  28. data/lib/webhookdb/async/job.rb +4 -0
  29. data/lib/webhookdb/async/scheduled_job.rb +4 -0
  30. data/lib/webhookdb/async.rb +2 -0
  31. data/lib/webhookdb/backfiller.rb +17 -4
  32. data/lib/webhookdb/concurrent.rb +96 -0
  33. data/lib/webhookdb/connection_cache.rb +29 -8
  34. data/lib/webhookdb/customer.rb +2 -2
  35. data/lib/webhookdb/database_document.rb +1 -1
  36. data/lib/webhookdb/db_adapter/default_sql.rb +1 -14
  37. data/lib/webhookdb/db_adapter/partition.rb +14 -0
  38. data/lib/webhookdb/db_adapter/partitioning.rb +8 -0
  39. data/lib/webhookdb/db_adapter/pg.rb +77 -5
  40. data/lib/webhookdb/db_adapter/snowflake.rb +15 -6
  41. data/lib/webhookdb/db_adapter.rb +24 -2
  42. data/lib/webhookdb/fixtures/logged_webhooks.rb +4 -0
  43. data/lib/webhookdb/fixtures/organization_error_handlers.rb +20 -0
  44. data/lib/webhookdb/http.rb +29 -15
  45. data/lib/webhookdb/icalendar.rb +30 -9
  46. data/lib/webhookdb/jobs/amigo_test_jobs.rb +1 -1
  47. data/lib/webhookdb/jobs/backfill.rb +21 -25
  48. data/lib/webhookdb/jobs/create_mirror_table.rb +3 -4
  49. data/lib/webhookdb/jobs/deprecated_jobs.rb +2 -0
  50. data/lib/webhookdb/jobs/emailer.rb +2 -1
  51. data/lib/webhookdb/jobs/front_signalwire_message_channel_sync_inbound.rb +15 -0
  52. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +7 -2
  53. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +74 -11
  54. data/lib/webhookdb/jobs/icalendar_enqueue_syncs_for_urls.rb +22 -0
  55. data/lib/webhookdb/jobs/icalendar_sync.rb +21 -9
  56. data/lib/webhookdb/jobs/increase_event_handler.rb +3 -2
  57. data/lib/webhookdb/jobs/logged_webhooks_replay.rb +5 -3
  58. data/lib/webhookdb/jobs/message_dispatched.rb +1 -0
  59. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +7 -0
  60. data/lib/webhookdb/jobs/monitor_metrics.rb +1 -1
  61. data/lib/webhookdb/jobs/organization_database_migration_notify.rb +32 -0
  62. data/lib/webhookdb/jobs/organization_database_migration_run.rb +4 -6
  63. data/lib/webhookdb/jobs/organization_error_handler_dispatch.rb +26 -0
  64. data/lib/webhookdb/jobs/prepare_database_connections.rb +1 -0
  65. data/lib/webhookdb/jobs/process_webhook.rb +11 -12
  66. data/lib/webhookdb/jobs/renew_watch_channel.rb +7 -10
  67. data/lib/webhookdb/jobs/replication_migration.rb +5 -2
  68. data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +1 -2
  69. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -2
  70. data/lib/webhookdb/jobs/send_invite.rb +3 -2
  71. data/lib/webhookdb/jobs/send_test_webhook.rb +1 -3
  72. data/lib/webhookdb/jobs/send_webhook.rb +4 -5
  73. data/lib/webhookdb/jobs/stale_row_deleter.rb +31 -0
  74. data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +3 -0
  75. data/lib/webhookdb/jobs/sync_target_run_sync.rb +9 -15
  76. data/lib/webhookdb/jobs/webhook_subscription_delivery_event.rb +5 -8
  77. data/lib/webhookdb/liquid/expose.rb +1 -1
  78. data/lib/webhookdb/liquid/filters.rb +1 -1
  79. data/lib/webhookdb/liquid/partial.rb +2 -2
  80. data/lib/webhookdb/logged_webhook/resilient.rb +3 -3
  81. data/lib/webhookdb/logged_webhook.rb +16 -2
  82. data/lib/webhookdb/message/email_transport.rb +1 -1
  83. data/lib/webhookdb/message.rb +2 -2
  84. data/lib/webhookdb/messages/error_generic_backfill.rb +2 -0
  85. data/lib/webhookdb/messages/error_icalendar_fetch.rb +2 -0
  86. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +2 -0
  87. data/lib/webhookdb/organization/alerting.rb +50 -4
  88. data/lib/webhookdb/organization/database_migration.rb +1 -1
  89. data/lib/webhookdb/organization/db_builder.rb +4 -3
  90. data/lib/webhookdb/organization/error_handler.rb +141 -0
  91. data/lib/webhookdb/organization.rb +62 -9
  92. data/lib/webhookdb/postgres/model_utilities.rb +2 -0
  93. data/lib/webhookdb/postgres.rb +1 -3
  94. data/lib/webhookdb/replicator/base.rb +136 -29
  95. data/lib/webhookdb/replicator/base_stale_row_deleter.rb +165 -0
  96. data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +0 -1
  97. data/lib/webhookdb/replicator/fake.rb +100 -88
  98. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +105 -44
  99. data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +17 -0
  100. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +144 -23
  101. data/lib/webhookdb/replicator/icalendar_event_v1.rb +20 -44
  102. data/lib/webhookdb/replicator/icalendar_event_v1_partitioned.rb +33 -0
  103. data/lib/webhookdb/replicator/intercom_contact_v1.rb +1 -0
  104. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +1 -0
  105. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +24 -2
  106. data/lib/webhookdb/replicator/partitionable_mixin.rb +116 -0
  107. data/lib/webhookdb/replicator/shopify_v1_mixin.rb +1 -1
  108. data/lib/webhookdb/replicator/signalwire_message_v1.rb +1 -2
  109. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  110. data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +0 -1
  111. data/lib/webhookdb/replicator.rb +4 -1
  112. data/lib/webhookdb/service/helpers.rb +4 -0
  113. data/lib/webhookdb/service/middleware.rb +6 -2
  114. data/lib/webhookdb/service_integration.rb +5 -0
  115. data/lib/webhookdb/signalwire.rb +1 -1
  116. data/lib/webhookdb/spec_helpers/async.rb +0 -4
  117. data/lib/webhookdb/spec_helpers/sentry.rb +32 -0
  118. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +87 -1
  119. data/lib/webhookdb/spec_helpers.rb +1 -0
  120. data/lib/webhookdb/sync_target.rb +195 -29
  121. data/lib/webhookdb/tasks/admin.rb +1 -1
  122. data/lib/webhookdb/tasks/annotate.rb +1 -1
  123. data/lib/webhookdb/tasks/db.rb +13 -1
  124. data/lib/webhookdb/tasks/docs.rb +1 -1
  125. data/lib/webhookdb/tasks/fixture.rb +1 -1
  126. data/lib/webhookdb/tasks/message.rb +1 -1
  127. data/lib/webhookdb/tasks/regress.rb +1 -1
  128. data/lib/webhookdb/tasks/release.rb +1 -1
  129. data/lib/webhookdb/tasks/sidekiq.rb +1 -1
  130. data/lib/webhookdb/tasks/specs.rb +1 -1
  131. data/lib/webhookdb/version.rb +1 -1
  132. data/lib/webhookdb/webhook_subscription.rb +2 -3
  133. data/lib/webhookdb.rb +3 -1
  134. metadata +88 -54
  135. data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +0 -21
  136. data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +0 -21
@@ -9,9 +9,7 @@ class Webhookdb::Jobs::SyncTargetRunSync
9
9
 
10
10
  sidekiq_options queue: "netout"
11
11
 
12
- def dependent_queues
13
- return ["critical"]
14
- end
12
+ def dependent_queues = ["critical"]
15
13
 
16
14
  def perform(sync_target_id)
17
15
  stgt = Webhookdb::SyncTarget[sync_target_id]
@@ -19,22 +17,18 @@ class Webhookdb::Jobs::SyncTargetRunSync
19
17
  # A sync target may be enqueued, but destroyed before the sync runs.
20
18
  # If so, log a warning. We see this on staging a lot,
21
19
  # but it does happen on production too, and should be expected.
22
- self.logger.info("missing_sync_target", sync_target_id:)
20
+ self.set_job_tags(result: "missing_sync_target", sync_target_id:)
23
21
  return
24
22
  end
25
- self.with_log_tags(
26
- sync_target_id: stgt.id,
27
- sync_target_connection_url: stgt.displaysafe_connection_url,
28
- sync_target_service_integration_service: stgt.service_integration.service_name,
29
- sync_target_service_integration_table: stgt.service_integration.table_name,
30
- ) do
31
- stgt.run_sync(now: Time.now)
23
+ self.set_job_tags(stgt.log_tags)
24
+ begin
25
+ started = Time.now
26
+ stgt.run_sync(now: started)
27
+ self.set_job_tags(result: "sync_target_synced", synced_at_of: started)
32
28
  rescue Webhookdb::SyncTarget::SyncInProgress
33
- Webhookdb::Idempotency.every(30.seconds).in_memory.under_key("sync_target_in_progress-#{stgt.id}") do
34
- self.logger.info("sync_target_already_in_progress")
35
- end
29
+ self.set_job_tags(result: "sync_target_already_in_progress")
36
30
  rescue Webhookdb::SyncTarget::Deleted
37
- self.logger.info("sync_target_deleted")
31
+ self.set_job_tags(result: "sync_target_deleted")
38
32
  end
39
33
  end
40
34
  end
@@ -12,18 +12,15 @@ class Webhookdb::Jobs::WebhookSubscriptionDeliveryEvent
12
12
 
13
13
  sidekiq_options queue: "netout"
14
14
 
15
- def dependent_queues
16
- return ["critical"]
17
- end
15
+ def dependent_queues = ["critical"]
18
16
 
19
17
  def perform(delivery_id)
20
18
  delivery = Webhookdb::WebhookSubscription::Delivery[delivery_id]
21
- Webhookdb::Async::JobLogger.with_log_tags(
19
+ Webhookdb::Async::JobLogger.set_job_tags(
22
20
  webhook_subscription_delivery_id: delivery.id,
23
21
  webhook_subscription_id: delivery.webhook_subscription_id,
24
- organization_key: delivery.webhook_subscription.fetch_organization,
25
- ) do
26
- delivery.attempt_delivery
27
- end
22
+ organization: delivery.webhook_subscription.fetch_organization,
23
+ )
24
+ delivery.attempt_delivery
28
25
  end
29
26
  end
@@ -24,4 +24,4 @@ class Webhookdb::Liquid::Expose < Liquid::Block
24
24
  end
25
25
  end
26
26
 
27
- Liquid::Template.register_tag("expose", Webhookdb::Liquid::Expose)
27
+ Liquid::Environment.default.register_tag("expose", Webhookdb::Liquid::Expose)
@@ -13,4 +13,4 @@ module Webhookdb::Liquid::Filters
13
13
  end
14
14
  end
15
15
 
16
- Liquid::Template.register_filter(Webhookdb::Liquid::Filters)
16
+ Liquid::Environment.default.register_filter(Webhookdb::Liquid::Filters)
@@ -6,7 +6,7 @@ require "liquid"
6
6
  class Webhookdb::Liquid::Partial < Liquid::Include
7
7
  def initialize(tag_name, name, options)
8
8
  name = "'partials/#{Regexp.last_match(1)}'" if name =~ /['"]([a-z0-9_]+)['"]/
9
- super(tag_name, name, options)
9
+ super
10
10
  end
11
11
  end
12
- Liquid::Template.register_tag("partial", Webhookdb::Liquid::Partial)
12
+ Liquid::Environment.default.register_tag("partial", Webhookdb::Liquid::Partial)
@@ -12,10 +12,10 @@ class Webhookdb::LoggedWebhook::Resilient
12
12
  str_payload = JSON.dump(kwargs)
13
13
  self.database_urls.each do |url|
14
14
  next unless self.write_to(url, service_integration_opaque_id, str_payload)
15
- self.logger.warn "resilient_insert_handled", error: e, **self._dburl_log_kwargs(url)
15
+ self.logger.warn "resilient_insert_handled", self._dburl_log_kwargs(url), e
16
16
  return true
17
17
  end
18
- self.logger.error "resilient_insert_unhandled", error: e, logged_webhook_kwargs: kwargs
18
+ self.logger.error "resilient_insert_unhandled", {logged_webhook_kwargs: kwargs}, e
19
19
  raise
20
20
  end
21
21
 
@@ -37,7 +37,7 @@ class Webhookdb::LoggedWebhook::Resilient
37
37
  end
38
38
  return true
39
39
  rescue StandardError => e
40
- self.logger.debug "resilient_insert_failure", error: e, **self._dburl_log_kwargs(dburl)
40
+ self.logger.debug "resilient_insert_failure", self._dburl_log_kwargs(dburl), e
41
41
  return false
42
42
  end
43
43
 
@@ -88,13 +88,26 @@ class Webhookdb::LoggedWebhook < Webhookdb::Postgres::Model(:logged_webhooks)
88
88
  unowned = self.where(organization_id: nil)
89
89
  successes = owned.where { response_status < 400 }
90
90
  failures = owned.where { response_status >= 400 }
91
+ # NOTE: This code is tightly coupled with indices created in 050_logged_webhooks_indices.rb
92
+ # We create a separate index for each operation; the indices (5 in total) cover the full combination of:
93
+ # - rows without an organization (idx 1)
94
+ # - rows with an organization
95
+ # - rows already truncated
96
+ # - rows with status < 400 (idx 2)
97
+ # - rows with status >= 400 (idx 3)
98
+ # - rows not truncated
99
+ # - rows with status < 400 (idx 4)
100
+ # - rows with status >= 400 (idx 5)
101
+ # Note that we only delete already-truncated rows so we can keep our indices smaller;
102
+ # since deletion ages are always older than truncation ages, this should not be a problem.
103
+
91
104
  # Delete old unowned
92
105
  unowned.where { inserted_at < now - DELETE_UNOWNED }.delete
93
106
  # Delete successes first so they don't have to be truncated
94
- successes.where { inserted_at < now - DELETE_SUCCESSES }.delete
107
+ successes.where { inserted_at < now - DELETE_SUCCESSES }.exclude(truncated_at: nil).delete
95
108
  self.truncate_dataset(successes.where { inserted_at < now - TRUNCATE_SUCCESSES })
96
109
  # Delete failures
97
- failures.where { inserted_at < now - DELETE_FAILURES }.delete
110
+ failures.where { inserted_at < now - DELETE_FAILURES }.exclude(truncated_at: nil).delete
98
111
  self.truncate_dataset(failures.where { inserted_at < now - TRUNCATE_FAILURES })
99
112
  end
100
113
 
@@ -145,6 +158,7 @@ class Webhookdb::LoggedWebhook < Webhookdb::Postgres::Model(:logged_webhooks)
145
158
  end
146
159
 
147
160
  def self.truncate_dataset(ds)
161
+ ds = ds.where(truncated_at: nil)
148
162
  return ds.update(request_body: "", request_headers: "{}", truncated_at: Time.now)
149
163
  end
150
164
 
@@ -12,7 +12,7 @@ class Webhookdb::Message::EmailTransport < Webhookdb::Message::Transport
12
12
  register_transport(:email)
13
13
 
14
14
  configurable(:email) do
15
- setting :allowlist, ["*@lithic.tech", "*@webhookdb.com"], convert: ->(s) { s.split }
15
+ setting :allowlist, ["*@lithic.tech", "*@webhookdb.com"], convert: lambda(&:split)
16
16
  setting :from, "WebhookDB <hello@webhookdb.com>"
17
17
 
18
18
  setting :smtp_host, "localhost"
@@ -29,8 +29,8 @@ module Webhookdb::Message
29
29
 
30
30
  configurable(:messages) do
31
31
  after_configured do
32
- Liquid::Template.error_mode = :strict
33
- Liquid::Template.file_system = Liquid::LocalFileSystem.new(DATA_DIR, "%s.liquid")
32
+ Liquid::Environment.default.error_mode = :strict
33
+ Liquid::Environment.default.file_system = Liquid::LocalFileSystem.new(DATA_DIR, "%s.liquid")
34
34
  end
35
35
  end
36
36
 
@@ -14,6 +14,8 @@ class Webhookdb::Messages::ErrorGenericBackfill < Webhookdb::Message::Template
14
14
  )
15
15
  end
16
16
 
17
+ attr_accessor :service_integration
18
+
17
19
  def initialize(service_integration, request_url:, request_method:, response_status:, response_body:)
18
20
  @service_integration = service_integration
19
21
  @request_url = request_url
@@ -9,6 +9,8 @@ class Webhookdb::Messages::ErrorIcalendarFetch < Webhookdb::Message::Template
9
9
  response_status: 403, request_url: "/foo", request_method: "GET", response_body: "hi",)
10
10
  end
11
11
 
12
+ attr_accessor :service_integration
13
+
12
14
  def initialize(service_integration, external_calendar_id, request_url:, request_method:, response_status:,
13
15
  response_body:)
14
16
  @service_integration = service_integration
@@ -19,6 +19,8 @@ class Webhookdb::Messages::ErrorSignalwireSendSms < Webhookdb::Message::Template
19
19
  )
20
20
  end
21
21
 
22
+ attr_accessor :service_integration
23
+
22
24
  def initialize(service_integration, request_url:, request_method:, response_status:, response_body:)
23
25
  @service_integration = service_integration
24
26
  @request_url = request_url
@@ -3,6 +3,14 @@
3
3
  require "appydays/configurable"
4
4
  require "appydays/loggable"
5
5
 
6
+ require "webhookdb/jobs/organization_error_handler_dispatch"
7
+
8
+ # Alert an organization when errors happen during webhook handling.
9
+ # These errors are explicitly managed in the handler code,
10
+ # and are usually things like outdated credentials.
11
+ # The alerts contain information about the error, and actions to take to fix the problem.
12
+ # If an organization has no +Webhookdb::Postgres::ErrorHandler+ registered,
13
+ # send an email to org admins instead.
6
14
  class Webhookdb::Organization::Alerting
7
15
  include Appydays::Configurable
8
16
 
@@ -13,6 +21,14 @@ class Webhookdb::Organization::Alerting
13
21
  # Each customer can only receive this many alerts for a given template per day.
14
22
  # Avoids spamming a customer when many rows of a replicator have problems.
15
23
  setting :max_alerts_per_customer_per_day, 15
24
+ # Timeout when POSTing to a customer-defined URL on errors.
25
+ # Should be relatively short.
26
+ setting :error_handler_timeout, 7
27
+ # How many times should we call an error handler before giving up?
28
+ # Error handlers are often lossy so it's not a big deal to give up.
29
+ setting :error_handler_retries, 5
30
+ # Wait this long before retrying an error handler.
31
+ setting :error_handler_retry_interval, 60
16
32
  end
17
33
 
18
34
  attr_reader :org
@@ -21,17 +37,47 @@ class Webhookdb::Organization::Alerting
21
37
  @org = org
22
38
  end
23
39
 
24
- # Dispatch the message template to administrators of the org.
40
+ # Dispatch an alert using the given message template.
41
+ # See +Webhookdb::Organization::Alerting+ for details about how alerts are dispatched.
25
42
  # @param message_template [Webhookdb::Message::Template]
26
- # @param separate_connection [true,false] If true, send the alert on a separate connection.
43
+ # @param separate_connection [true,false] Only relevant if the organization has no error handlers
44
+ # and email alerting (+dispatch_alert_default+) is used. If true, send the alert on a separate connection.
27
45
  # See +Webhookdb::Idempotency+. Defaults to true since this is an alert method and we
28
46
  # don't want it to error accidentally, if the code is called from an unexpected situation.
29
47
  def dispatch_alert(message_template, separate_connection: true)
48
+ self.validate_template(message_template)
49
+ if self.org.error_handlers.empty?
50
+ self.dispatch_alert_default(message_template, separate_connection:)
51
+ return
52
+ end
53
+ self.org.error_handlers.each do |eh|
54
+ payload = eh.payload_for_template(message_template)
55
+ # It's possible that the template includes caller-provided values including improperly-encoded strings.
56
+ # Sidekiq's strict job args will do a dump/parse to check for valid args,
57
+ # which will potentially fail if valid utf-8 bytes are in a string that's encoded as ascii.
58
+ # Really hard to explain, so see the specs, but there's nothing we can do about invalid content
59
+ # other than not error.
60
+ payload = JSON.parse(JSON.dump(payload))
61
+ Webhookdb::Jobs::OrganizationErrorHandlerDispatch.perform_async(eh.id, payload.as_json)
62
+ end
63
+ end
64
+
65
+ private def validate_template(message_template)
30
66
  unless message_template.respond_to?(:signature)
31
- raise Webhookdb::InvalidPrecondition,
32
- "message template #{message_template.template_name} must define a #signature method, " \
67
+ msg = "message template #{message_template.template_name} must define a #signature method, " \
33
68
  "which is a unique identity for this error type, used for grouping and idempotency"
69
+ raise Webhookdb::InvalidPrecondition, msg
70
+
71
+ end
72
+ unless message_template.respond_to?(:service_integration)
73
+ msg = "message template #{message_template.template_name} must return " \
74
+ "its ServiceIntegration from #service_integration"
75
+ raise Webhookdb::InvalidPrecondition, msg
34
76
  end
77
+ return true
78
+ end
79
+
80
+ def dispatch_alert_default(message_template, separate_connection:)
35
81
  signature = message_template.signature
36
82
  max_alerts_per_customer_per_day = Webhookdb::Organization::Alerting.max_alerts_per_customer_per_day
37
83
  yesterday = Time.now - 24.hours
@@ -112,7 +112,7 @@ class Webhookdb::Organization::DatabaseMigration < Webhookdb::Postgres::Model(:o
112
112
  tscol = svc.timestamp_column.name
113
113
  dstdb[svc.qualified_table_sequel_identifier].
114
114
  insert_conflict(
115
- target: svc.remote_key_column.name,
115
+ target: svc._upsert_conflict_target,
116
116
  update_where: svc._update_where_expr,
117
117
  ).multi_insert(chunk)
118
118
  self.update(last_migrated_timestamp: chunk.last[tscol])
@@ -282,7 +282,7 @@ class Webhookdb::Organization::DbBuilder
282
282
  # (and we probably don't want the admin role trying to delete itself).
283
283
  def remove_related_database
284
284
  return if @org.admin_connection_url_raw.blank?
285
- superuser_str = self._find_superuser_url_str
285
+ superuser_str = self.find_superuser_url_str
286
286
  # Cannot use conn cache since we may be removing ourselves
287
287
  borrow_conn(superuser_str) do |conn|
288
288
  case self.class.isolation_mode
@@ -309,7 +309,8 @@ class Webhookdb::Organization::DbBuilder
309
309
  end
310
310
  end
311
311
 
312
- protected def _find_superuser_url_str
312
+ # Find the superuser connection URL for the organization's database.
313
+ def find_superuser_url_str
313
314
  admin_url = URI.parse(@org.admin_connection_url_raw)
314
315
  superuser_str = self.class.available_server_urls.find do |sstr|
315
316
  surl = URI.parse(sstr)
@@ -325,7 +326,7 @@ class Webhookdb::Organization::DbBuilder
325
326
  def roll_connection_credentials
326
327
  raise IsolatedOperationError, "cannot roll credentials without a user isolation mode" unless
327
328
  self.class.isolate?(USER)
328
- superuser_uri = URI(self._find_superuser_url_str)
329
+ superuser_uri = URI(self.find_superuser_url_str)
329
330
  orig_readonly_user = URI(@org.readonly_connection_url_raw).user
330
331
  ro_user = self.randident("ro")
331
332
  ro_pwd = self.randident
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "premailer"
4
+
5
+ class Webhookdb::Organization::ErrorHandler < Webhookdb::Postgres::Model(:organization_error_handlers)
6
+ include Webhookdb::Dbutil
7
+
8
+ DOCS_URL = "https://docs.webhookdb.com/docs/integrating/error-handlers.html"
9
+
10
+ plugin :timestamps
11
+
12
+ many_to_one :organization, class: "Webhookdb::Organization"
13
+ many_to_one :created_by, class: "Webhookdb::Customer"
14
+
15
+ # @param tmpl [Webhookdb::Message::Template]
16
+ def payload_for_template(tmpl)
17
+ params = {
18
+ error_type: tmpl.class.name.split("::").last.underscore,
19
+ details: tmpl.liquid_drops.to_h,
20
+ signature: tmpl.signature,
21
+ organization_key: self.organization.key,
22
+ service_integration_id: tmpl.service_integration.opaque_id,
23
+ service_integration_name: tmpl.service_integration.service_name,
24
+ service_integration_table: tmpl.service_integration.table_name,
25
+ }
26
+ recipient = Webhookdb::Message::Transport.for(:email).recipient(Webhookdb.support_email)
27
+ message = Webhookdb::Message.render(tmpl, :email, recipient)
28
+ message = message.to_s.strip
29
+ message = Premailer.new(
30
+ message,
31
+ with_html_string: true,
32
+ warn_level: Premailer::Warnings::SAFE,
33
+ )
34
+ message = message.to_plain_text
35
+ params[:message] = message
36
+ return params
37
+ end
38
+
39
+ def dispatch(payload)
40
+ if self.sentry?
41
+ self._handle_sentry(payload)
42
+ return
43
+ end
44
+
45
+ Webhookdb::Http.post(
46
+ self.url,
47
+ payload,
48
+ timeout: Webhookdb::Organization::Alerting.error_handler_timeout,
49
+ logger: self.logger,
50
+ )
51
+ end
52
+
53
+ def sentry?
54
+ u = URI(self.url)
55
+ return u.scheme == "sentry" || u.host&.end_with?("sentry.io")
56
+ end
57
+
58
+ MAX_SENTRY_TAG_CHARS = 200
59
+
60
+ # See https://develop.sentry.dev/sdk/data-model/envelopes/ for directly posting to Sentry.
61
+ # We do NOT want to use the SDK here, since we do not want to leak anything,
62
+ # and anyway, the runtime information is not important.
63
+ def _handle_sentry(payload)
64
+ payload = payload.deep_symbolize_keys
65
+ now = Time.now.utc
66
+ # We can assume the url is the Sentry DSN
67
+ u = URI(self.url)
68
+ key = u.user
69
+ project_id = u.path.delete("/")
70
+ # Give some valid value for this, though it's not accurate.
71
+ client = "sentry-ruby/5.22.1"
72
+ ts = now.to_i
73
+ # Auth headers are done by capturing an actual request. The docs aren't clear about their format.
74
+ # It's possible using the DSN auth would also work but let's use this.
75
+ headers = {
76
+ "Content-Type" => "application/x-sentry-envelope",
77
+ "X-Sentry-Auth" => "Sentry sentry_version=7, sentry_key=#{key}, sentry_client=#{client}, sentry_timestamp=#{ts}",
78
+ }
79
+ event_id = Uuidx.v4
80
+ # The first line will be used as the title.
81
+ message = "WebhookDB Error in #{payload.fetch(:service_integration_name)}\n\n#{payload.fetch(:message)}"
82
+ # Let the caller set the level through query params
83
+ level = URI.decode_www_form(u.query || "").to_h.fetch("level", "warning")
84
+
85
+ # Split structured data into 'extra' (cannot be searched on, just shows in the UI)
86
+ # and 'tags' (can be searched/faceted on, shows in the right bar).
87
+ ignore_tags = Webhookdb::Message::Template.new.liquid_drops.keys.to_set
88
+ tags, extra = payload.fetch(:details).partition do |k, v|
89
+ # Non-strings are always tags
90
+ next true unless v.is_a?(String)
91
+ # Never tag on basic stuff that doesn't change ever
92
+ next false if ignore_tags.include?(k)
93
+ # Unstructured strings may include spaces or braces, and are not tags
94
+ next false if v.include?(" ") || v.include?("{")
95
+ # If it's a small string, treat it as a tag.
96
+ v.size < MAX_SENTRY_TAG_CHARS
97
+ end
98
+
99
+ # Envelope structure is a multiline JSON file, I guess jsonl format
100
+ envelopes = [
101
+ {event_id:, sent_at: now.iso8601},
102
+ {type: "event", content_type: "application/json"},
103
+ {
104
+ event_id:,
105
+ timestamp: now.iso8601,
106
+ platform: "ruby",
107
+ level:,
108
+ transaction: payload.fetch(:service_integration_table),
109
+ release: "webhookdb@#{Webhookdb::RELEASE}",
110
+ environment: Webhookdb::RACK_ENV,
111
+ tags: tags.to_h,
112
+ extra: extra.to_h,
113
+ # We should use the same grouping for these messages as we would for emails
114
+ fingerprint: [payload.fetch(:signature)],
115
+ message: message,
116
+ },
117
+ ]
118
+ body = envelopes.map(&:to_json).join("\n")
119
+ store_url = URI(self.url)
120
+ store_url.scheme = "https" if store_url.scheme == "sentry"
121
+ store_url.user = nil
122
+ store_url.password = nil
123
+ store_url.path = "/api/#{project_id}/envelope/"
124
+ store_url.query = ""
125
+ Webhookdb::Http.post(
126
+ store_url.to_s,
127
+ body,
128
+ headers:,
129
+ timeout: Webhookdb::Organization::Alerting.error_handler_timeout,
130
+ logger: self.logger,
131
+ )
132
+ end
133
+
134
+ #
135
+ # :Sequel Hooks:
136
+ #
137
+
138
+ def before_create
139
+ self[:opaque_id] ||= Webhookdb::Id.new_opaque_id("oeh")
140
+ end
141
+ end
@@ -37,6 +37,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
37
37
  adder: ->(om) { om.update(organization_id: id, verified: false) },
38
38
  order: :id
39
39
  one_to_many :service_integrations, class: "Webhookdb::ServiceIntegration", order: :id
40
+ one_to_many :error_handlers, class: "Webhookdb::Organization::ErrorHandler", order: :id
40
41
  one_to_many :saved_queries, class: "Webhookdb::SavedQuery", order: :id
41
42
  one_to_many :saved_views, class: "Webhookdb::SavedView", order: :id
42
43
  one_to_many :webhook_subscriptions, class: "Webhookdb::WebhookSubscription", order: :id
@@ -158,7 +159,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
158
159
  result = self.execute_readonly_query(sql)
159
160
  return result, nil
160
161
  rescue Sequel::DatabaseError => e
161
- self.logger.error("db_query_database_error", error: e)
162
+ self.logger.error("db_query_database_error", e)
162
163
  # We want to handle InsufficientPrivileges and UndefinedTable explicitly
163
164
  # since we can hint the user at what to do.
164
165
  # Otherwise, we should just return the Postgres exception.
@@ -231,9 +232,10 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
231
232
  return "#{self.name} (#{self.key})"
232
233
  end
233
234
 
234
- def prepare_database_connections?
235
- return self.prepare_database_connections(safe: true)
236
- end
235
+ # @return [Webhookdb::Organization::DbBuilder]
236
+ def db_builder = Webhookdb::Organization::DbBuilder.new(self)
237
+
238
+ def prepare_database_connections? = self.prepare_database_connections(safe: true)
237
239
 
238
240
  # Build the org-specific users, database, and set our connection URLs to it.
239
241
  # @param safe [*] If true, noop if connection urls are set.
@@ -244,7 +246,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
244
246
  return if safe
245
247
  raise Webhookdb::InvalidPrecondition, "connections already set"
246
248
  end
247
- builder = Webhookdb::Organization::DbBuilder.new(self)
249
+ builder = self.db_builder
248
250
  builder.prepare_database_connections
249
251
  self.admin_connection_url_raw = builder.admin_url
250
252
  self.readonly_connection_url_raw = builder.readonly_url
@@ -266,7 +268,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
266
268
  end
267
269
  # Use the raw URL, even though we know at this point
268
270
  # public_host is empty so raw and public host urls are the same.
269
- Webhookdb::Organization::DbBuilder.new(self).create_public_host_cname(self.readonly_connection_url_raw)
271
+ self.db_builder.create_public_host_cname(self.readonly_connection_url_raw)
270
272
  self.save_changes
271
273
  end
272
274
  end
@@ -276,7 +278,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
276
278
  def remove_related_database
277
279
  self.db.transaction do
278
280
  self.lock!
279
- Webhookdb::Organization::DbBuilder.new(self).remove_related_database
281
+ self.db_builder.remove_related_database
280
282
  self.admin_connection_url_raw = ""
281
283
  self.readonly_connection_url_raw = ""
282
284
  self.save_changes
@@ -377,7 +379,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
377
379
  def roll_database_credentials
378
380
  self.db.transaction do
379
381
  self.lock!
380
- builder = Webhookdb::Organization::DbBuilder.new(self)
382
+ builder = self.db_builder
381
383
  builder.roll_connection_credentials
382
384
  self.admin_connection_url_raw = builder.admin_url
383
385
  self.readonly_connection_url_raw = builder.readonly_url
@@ -389,7 +391,7 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
389
391
  Webhookdb::DBAdapter.validate_identifier!(schema, type: "schema")
390
392
  Webhookdb::Organization::DatabaseMigration.guard_ongoing!(self)
391
393
  raise SchemaMigrationError, "destination and target schema are the same" if schema == self.replication_schema
392
- builder = Webhookdb::Organization::DbBuilder.new(self)
394
+ builder = self.db_builder
393
395
  sql = builder.migration_replication_schema_sql(self.replication_schema, schema)
394
396
  self.admin_connection(transaction: true) do |db|
395
397
  db << sql
@@ -512,6 +514,57 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
512
514
 
513
515
  # @!attribute service_integrations
514
516
  # @return [Array<Webhookdb::ServiceIntegration>]
517
+
518
+ # @!attribute created_at
519
+ # @return [Time,nil]
520
+
521
+ # @!attribute updated_at
522
+ # @return [Time,nil]
523
+
524
+ # @!attribute soft_deleted_at
525
+ # @return [Time,nil]
526
+
527
+ # @!attribute name
528
+ # @return [String]
529
+
530
+ # @!attribute key
531
+ # @return [String]
532
+
533
+ # @!attribute billing_email
534
+ # @return [String]
535
+
536
+ # @!attribute stripe_customer_id
537
+ # @return [String]
538
+
539
+ # @!attribute readonly_connection_url_raw
540
+ # @return [String]
541
+
542
+ # @!attribute admin_connection_url_raw
543
+ # @return [String]
544
+
545
+ # @!attribute public_host
546
+ # @return [String]
547
+
548
+ # @!attribute cloudflare_dns_record_json
549
+ # @return [Hash]
550
+
551
+ # @!attribute replication_schema
552
+ # @return [String]
553
+
554
+ # @!attribute job_semaphore_size
555
+ # @return [Integer]
556
+
557
+ # @!attribute minimum_sync_seconds
558
+ # @return [Integer]
559
+
560
+ # @!attribute sync_target_timeout
561
+ # @return [Integer]
562
+
563
+ # @!attribute max_query_rows
564
+ # @return [Integer]
565
+
566
+ # @!attribute priority_backfill
567
+ # @return [true,false]
515
568
  end
516
569
 
517
570
  require "webhookdb/organization/alerting"
@@ -208,7 +208,9 @@ module Webhookdb::Postgres::ModelUtilities
208
208
  rescue NoMethodError
209
209
  encrypted = Set.new
210
210
  end
211
+ text_search_col = self.class.respond_to?(:text_search_column) && self.class.text_search_column
211
212
  values = values.map do |(k, v)|
213
+ next "#{k}: {#{v.size}}" if k == text_search_col
212
214
  k = k.to_s
213
215
  v = if v.is_a?(Time)
214
216
  self.inspect_time(v)
@@ -59,6 +59,7 @@ module Webhookdb::Postgres
59
59
  "webhookdb/oauth/session",
60
60
  "webhookdb/organization",
61
61
  "webhookdb/organization/database_migration",
62
+ "webhookdb/organization/error_handler",
62
63
  "webhookdb/organization_membership",
63
64
  "webhookdb/role",
64
65
  "webhookdb/saved_query",
@@ -82,7 +83,6 @@ module Webhookdb::Postgres
82
83
  ### Register the given +superclass+ as a base class for a set of models, for operations
83
84
  ### which should happen on all the current database connections.
84
85
  def self.register_model_superclass(superclass)
85
- self.logger.debug "Registered model superclass: %p" % [superclass]
86
86
  self.model_superclasses << superclass
87
87
  end
88
88
 
@@ -93,8 +93,6 @@ module Webhookdb::Postgres
93
93
 
94
94
  ### Add a +path+ to require once the database connection is set.
95
95
  def self.register_model(path)
96
- self.logger.debug "Registered model for requiring: %s" % [path]
97
-
98
96
  # If the connection's set, require the path immediately.
99
97
  if self.model_superclasses.any?(&:db)
100
98
  Appydays::Loggable[self].silence(:fatal) do