webhookdb 1.3.1 → 1.4.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/admin-dist/assets/{index-6aebf805.js → index-9306dd28.js} +39 -39
  3. data/admin-dist/index.html +1 -1
  4. data/data/messages/templates/errors/generic_backfill.email.liquid +30 -0
  5. data/data/messages/templates/errors/icalendar_fetch.email.liquid +8 -2
  6. data/data/messages/templates/specs/with_fields.email.liquid +6 -0
  7. data/db/migrations/045_system_log.rb +15 -0
  8. data/db/migrations/046_indices.rb +14 -0
  9. data/lib/webhookdb/admin.rb +6 -0
  10. data/lib/webhookdb/admin_api/data_provider.rb +1 -0
  11. data/lib/webhookdb/admin_api/entities.rb +8 -0
  12. data/lib/webhookdb/aggregate_result.rb +1 -1
  13. data/lib/webhookdb/api/helpers.rb +17 -0
  14. data/lib/webhookdb/api/organizations.rb +6 -0
  15. data/lib/webhookdb/api/service_integrations.rb +1 -0
  16. data/lib/webhookdb/connection_cache.rb +29 -3
  17. data/lib/webhookdb/console.rb +1 -1
  18. data/lib/webhookdb/customer/reset_code.rb +1 -1
  19. data/lib/webhookdb/customer.rb +3 -2
  20. data/lib/webhookdb/db_adapter.rb +1 -1
  21. data/lib/webhookdb/dbutil.rb +2 -0
  22. data/lib/webhookdb/errors.rb +34 -0
  23. data/lib/webhookdb/http.rb +1 -1
  24. data/lib/webhookdb/jobs/deprecated_jobs.rb +1 -0
  25. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +105 -0
  26. data/lib/webhookdb/jobs/monitor_metrics.rb +29 -0
  27. data/lib/webhookdb/jobs/renew_watch_channel.rb +3 -0
  28. data/lib/webhookdb/message/transport.rb +1 -1
  29. data/lib/webhookdb/message.rb +53 -2
  30. data/lib/webhookdb/messages/error_generic_backfill.rb +45 -0
  31. data/lib/webhookdb/messages/error_icalendar_fetch.rb +3 -0
  32. data/lib/webhookdb/messages/specs.rb +16 -0
  33. data/lib/webhookdb/organization/alerting.rb +7 -3
  34. data/lib/webhookdb/organization/database_migration.rb +1 -1
  35. data/lib/webhookdb/organization/db_builder.rb +1 -1
  36. data/lib/webhookdb/organization.rb +14 -1
  37. data/lib/webhookdb/postgres/model.rb +1 -0
  38. data/lib/webhookdb/postgres.rb +2 -1
  39. data/lib/webhookdb/replicator/base.rb +66 -39
  40. data/lib/webhookdb/replicator/column.rb +2 -0
  41. data/lib/webhookdb/replicator/fake.rb +6 -0
  42. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +28 -19
  43. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +55 -11
  44. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +25 -4
  45. data/lib/webhookdb/replicator/signalwire_message_v1.rb +31 -0
  46. data/lib/webhookdb/replicator/transistor_episode_v1.rb +11 -5
  47. data/lib/webhookdb/replicator/webhook_request.rb +8 -0
  48. data/lib/webhookdb/replicator.rb +2 -2
  49. data/lib/webhookdb/service/view_api.rb +1 -1
  50. data/lib/webhookdb/service.rb +10 -10
  51. data/lib/webhookdb/service_integration.rb +14 -1
  52. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +153 -64
  53. data/lib/webhookdb/sync_target.rb +7 -5
  54. data/lib/webhookdb/system_log_event.rb +9 -0
  55. data/lib/webhookdb/version.rb +1 -1
  56. data/lib/webhookdb/webhook_subscription.rb +1 -1
  57. data/lib/webhookdb.rb +31 -7
  58. metadata +32 -16
  59. data/lib/webhookdb/jobs/customer_created_notify_internal.rb +0 -22
  60. /data/lib/webhookdb/jobs/{logged_webhook_replay.rb → logged_webhooks_replay.rb} +0 -0
  61. /data/lib/webhookdb/jobs/{logged_webhook_resilient_replay.rb → logged_webhooks_resilient_replay.rb} +0 -0
  62. /data/lib/webhookdb/jobs/{webhook_subscription_delivery_attempt.rb → webhook_subscription_delivery_event.rb} +0 -0
  63. /data/lib/webhookdb/jobs/{webhook_resource_notify_integrations.rb → webhookdb_resource_notify_integrations.rb} +0 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webhookdb/message/template"
4
+
5
+ class Webhookdb::Messages::ErrorGenericBackfill < Webhookdb::Message::Template
6
+ def self.fixtured(_recipient)
7
+ sint = Webhookdb::Fixtures.service_integration.create
8
+ return self.new(
9
+ sint,
10
+ response_status: 422,
11
+ request_url: "https://whdbtest.signalwire.com/2010-04-01/Accounts/projid/Messages.json",
12
+ request_method: "POST",
13
+ response_body: "Unauthorized",
14
+ )
15
+ end
16
+
17
+ def initialize(service_integration, request_url:, request_method:, response_status:, response_body:)
18
+ @service_integration = service_integration
19
+ @request_url = request_url
20
+ @request_method = request_method
21
+ @response_status = response_status
22
+ @response_body = response_body
23
+ super()
24
+ end
25
+
26
+ def signature
27
+ # Only alert on the backfill once a day
28
+ return "msg-#{self.full_template_name}-sint:#{@service_integration.id}"
29
+ end
30
+
31
+ def template_folder = "errors"
32
+ def template_name = "generic_backfill"
33
+
34
+ def liquid_drops
35
+ return super.merge(
36
+ friendly_name: @service_integration.replicator.descriptor.resource_name_singular,
37
+ service_name: @service_integration.service_name,
38
+ opaque_id: @service_integration.opaque_id,
39
+ request_method: @request_method,
40
+ request_url: @request_url,
41
+ response_status: @response_status,
42
+ response_body: @response_body,
43
+ )
44
+ end
45
+ end
@@ -36,6 +36,9 @@ class Webhookdb::Messages::ErrorIcalendarFetch < Webhookdb::Message::Template
36
36
  response_status: @response_status,
37
37
  response_body: @response_body,
38
38
  external_calendar_id: @external_calendar_id,
39
+ webhook_endpoint: @service_integration.replicator.webhook_endpoint,
40
+ org_name: @service_integration.organization.name,
41
+ org_key: @service_integration.organization.key,
39
42
  )
40
43
  end
41
44
  end
@@ -27,6 +27,22 @@ module Webhookdb::Messages::Testers
27
27
  end
28
28
  end
29
29
 
30
+ class WithFields < Base
31
+ # noinspection RubyInstanceVariableNamingConvention
32
+ def initialize(a: nil, b: nil, c: nil, d: nil, e: nil)
33
+ @a = a
34
+ @b = b
35
+ @c = c
36
+ @d = d
37
+ @e = e
38
+ super()
39
+ end
40
+
41
+ def liquid_drops
42
+ return super.merge(a: @a, b: @b, c: @c, d: @d, e: @e)
43
+ end
44
+ end
45
+
30
46
  class Nonextant < Base
31
47
  end
32
48
 
@@ -23,7 +23,10 @@ class Webhookdb::Organization::Alerting
23
23
 
24
24
  # Dispatch the message template to administrators of the org.
25
25
  # @param message_template [Webhookdb::Message::Template]
26
- def dispatch_alert(message_template)
26
+ # @param separate_connection [true,false] If true, send the alert on a separate connection.
27
+ # See +Webhookdb::Idempotency+. Defaults to true since this is an alert method and we
28
+ # don't want it to error accidentally, if the code is called from an unexpected situation.
29
+ def dispatch_alert(message_template, separate_connection: true)
27
30
  unless message_template.respond_to?(:signature)
28
31
  raise Webhookdb::InvalidPrecondition,
29
32
  "message template #{message_template.template_name} must define a #signature method, " \
@@ -33,8 +36,9 @@ class Webhookdb::Organization::Alerting
33
36
  max_alerts_per_customer_per_day = Webhookdb::Organization::Alerting.max_alerts_per_customer_per_day
34
37
  yesterday = Time.now - 24.hours
35
38
  self.org.admin_customers.each do |c|
36
- idemkey = "orgalert-#{signature}-#{c.id}"
37
- Webhookdb::Idempotency.every(Webhookdb::Organization::Alerting.interval).under_key(idemkey) do
39
+ idem = Webhookdb::Idempotency.every(Webhookdb::Organization::Alerting.interval)
40
+ idem = idem.using_seperate_connection if separate_connection
41
+ idem.under_key("orgalert-#{signature}-#{c.id}") do
38
42
  sent_last_day = Webhookdb::Message::Delivery.
39
43
  where(template: message_template.full_template_name, recipient: c).
40
44
  where { created_at > yesterday }.
@@ -4,7 +4,7 @@ class Webhookdb::Organization::DatabaseMigration < Webhookdb::Postgres::Model(:o
4
4
  include Webhookdb::Dbutil
5
5
 
6
6
  class MigrationInProgress < Webhookdb::DatabaseLocked; end
7
- class MigrationAlreadyFinished < StandardError; end
7
+ class MigrationAlreadyFinished < Webhookdb::WebhookdbError; end
8
8
 
9
9
  plugin :timestamps
10
10
  plugin :text_searchable, terms: [:organization, :started_by]
@@ -22,7 +22,7 @@ class Webhookdb::Organization::DbBuilder
22
22
  include Webhookdb::Dbutil
23
23
  extend Webhookdb::MethodUtilities
24
24
 
25
- class IsolatedOperationError < StandardError; end
25
+ class IsolatedOperationError < Webhookdb::ProgrammingError; end
26
26
 
27
27
  DATABASE = "database"
28
28
  SCHEMA = "schema"
@@ -7,7 +7,9 @@ require "webhookdb/stripe"
7
7
  require "webhookdb/jobs/replication_migration"
8
8
 
9
9
  class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
10
- class SchemaMigrationError < StandardError; end
10
+ include Webhookdb::Admin::Linked
11
+
12
+ class SchemaMigrationError < Webhookdb::ProgrammingError; end
11
13
 
12
14
  plugin :timestamps
13
15
  plugin :soft_deletes
@@ -454,6 +456,17 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
454
456
  return self.add_all_membership(opts)
455
457
  end
456
458
 
459
+ def close(confirm:)
460
+ raise Webhookdb::InvalidPrecondition, "confirm must be true to close the org" unless confirm
461
+ unless self.service_integrations_dataset.empty?
462
+ msg = "Organization[#{self.key} cannot close with active service integrations"
463
+ raise Webhookdb::InvalidPrecondition, msg
464
+ end
465
+ memberships = self.all_memberships_dataset.all.each(&:destroy)
466
+ self.destroy
467
+ return [self, memberships]
468
+ end
469
+
457
470
  # SUBSCRIPTION PERMISSIONS
458
471
 
459
472
  def active_subscription?
@@ -7,6 +7,7 @@ require "sequel"
7
7
  require "tsort"
8
8
 
9
9
  require "webhookdb"
10
+ require "webhookdb/admin"
10
11
  require "webhookdb/postgres"
11
12
  require "webhookdb/postgres/validations"
12
13
  require "webhookdb/postgres/model_utilities"
@@ -13,7 +13,7 @@ module Webhookdb::Postgres
13
13
  extend Webhookdb::MethodUtilities
14
14
  include Appydays::Loggable
15
15
 
16
- class InTransaction < StandardError; end
16
+ class InTransaction < Webhookdb::ProgrammingError; end
17
17
 
18
18
  singleton_attr_accessor :unsafe_skip_transaction_check
19
19
  @unsafe_skip_transaction_check = false
@@ -66,6 +66,7 @@ module Webhookdb::Postgres
66
66
  "webhookdb/service_integration",
67
67
  "webhookdb/subscription",
68
68
  "webhookdb/sync_target",
69
+ "webhookdb/system_log_event",
69
70
  "webhookdb/webhook_subscription",
70
71
  "webhookdb/webhook_subscription/delivery",
71
72
  ].freeze
@@ -651,6 +651,9 @@ for information on how to refresh data.)
651
651
  # @param [Webhookdb::Replicator::WebhookRequest] request
652
652
  def upsert_webhook(request, **kw)
653
653
  return self._upsert_webhook(request, **kw)
654
+ rescue Amigo::Retry::Error
655
+ # Do not log this since it's expected/handled by Amigo
656
+ raise
654
657
  rescue StandardError => e
655
658
  self.logger.error("upsert_webhook_error", request: request.as_json, error: e)
656
659
  raise
@@ -999,42 +1002,18 @@ for information on how to refresh data.)
999
1002
  job.update(started_at: Time.now)
1000
1003
 
1001
1004
  backfillers = self._backfillers(**job.criteria.symbolize_keys)
1002
- if self._parallel_backfill && self._parallel_backfill > 1
1003
- # Create a dedicated threadpool for these backfillers,
1004
- # with max parallelism determined by the replicator.
1005
- pool = Concurrent::FixedThreadPool.new(self._parallel_backfill)
1006
- # Record any errors that occur, since they won't raise otherwise.
1007
- # Initialize a sized array to avoid any potential race conditions (though GIL should make it not an issue?).
1008
- errors = Array.new(backfillers.size)
1009
- backfillers.each_with_index do |bf, idx|
1010
- pool.post do
1011
- bf.backfill(last_backfilled)
1012
- rescue StandardError => e
1013
- errors[idx] = e
1014
- end
1015
- end
1016
- # We've enqueued all backfillers; do not accept anymore work.
1017
- pool.shutdown
1018
- loop do
1019
- # We want to stop early if we find an error, so check for errors every 10 seconds.
1020
- completed = pool.wait_for_termination(10)
1021
- first_error = errors.find { |e| !e.nil? }
1022
- if first_error.nil?
1023
- # No error, and wait_for_termination returned true, so all work is done.
1024
- break if completed
1025
- # No error, but work is still going on, so loop again.
1026
- next
1027
- end
1028
- # We have an error; don't run any more backfillers.
1029
- pool.kill
1030
- # Wait for all ongoing backfills before raising.
1031
- pool.wait_for_termination
1032
- raise first_error
1005
+ begin
1006
+ if self._parallel_backfill && self._parallel_backfill > 1
1007
+ _do_parallel_backfill(backfillers, last_backfilled)
1008
+ else
1009
+ _do_serial_backfill(backfillers, last_backfilled)
1033
1010
  end
1034
- else
1035
- backfillers.each do |backfiller|
1036
- backfiller.backfill(last_backfilled)
1011
+ rescue StandardError => e
1012
+ if self.on_backfill_error(e) == true
1013
+ job.update(finished_at: Time.now)
1014
+ return
1037
1015
  end
1016
+ raise e
1038
1017
  end
1039
1018
 
1040
1019
  sint.update(last_backfilled_at: new_last_backfilled) if job.incremental?
@@ -1042,6 +1021,54 @@ for information on how to refresh data.)
1042
1021
  job.enqueue_children
1043
1022
  end
1044
1023
 
1024
+ protected def _do_parallel_backfill(backfillers, last_backfilled)
1025
+ # Create a dedicated threadpool for these backfillers,
1026
+ # with max parallelism determined by the replicator.
1027
+ pool = Concurrent::FixedThreadPool.new(self._parallel_backfill)
1028
+ # Record any errors that occur, since they won't raise otherwise.
1029
+ # Initialize a sized array to avoid any potential race conditions (though GIL should make it not an issue?).
1030
+ errors = Array.new(backfillers.size)
1031
+ backfillers.each_with_index do |bf, idx|
1032
+ pool.post do
1033
+ bf.backfill(last_backfilled)
1034
+ rescue StandardError => e
1035
+ errors[idx] = e
1036
+ end
1037
+ end
1038
+ # We've enqueued all backfillers; do not accept anymore work.
1039
+ pool.shutdown
1040
+ loop do
1041
+ # We want to stop early if we find an error, so check for errors every 10 seconds.
1042
+ completed = pool.wait_for_termination(10)
1043
+ first_error = errors.find { |e| !e.nil? }
1044
+ if first_error.nil?
1045
+ # No error, and wait_for_termination returned true, so all work is done.
1046
+ break if completed
1047
+ # No error, but work is still going on, so loop again.
1048
+ next
1049
+ end
1050
+ # We have an error; don't run any more backfillers.
1051
+ pool.kill
1052
+ # Wait for all ongoing backfills before raising.
1053
+ pool.wait_for_termination
1054
+ raise first_error
1055
+ end
1056
+ end
1057
+
1058
+ protected def _do_serial_backfill(backfillers, last_backfilled)
1059
+ backfillers.each do |backfiller|
1060
+ backfiller.backfill(last_backfilled)
1061
+ end
1062
+ end
1063
+
1064
+ # Called when the #backfill method errors.
1065
+ # This can do something like dispatch a developer alert.
1066
+ # The handler must raise in order to stop the job from processing-
1067
+ # if nothing is raised, the original exception will be raised instead.
1068
+ # By default, this method noops, so the original exception is raised.
1069
+ # @param e [Exception]
1070
+ def on_backfill_error(e) = nil
1071
+
1045
1072
  # If this replicator supports backfilling in parallel (running multiple backfillers at a time),
1046
1073
  # return the degree of paralellism (or nil if not running in parallel).
1047
1074
  # We leave parallelism up to the replicator, not CPU count, since most work
@@ -1096,15 +1123,15 @@ for information on how to refresh data.)
1096
1123
 
1097
1124
  def fetch_backfill_page(pagination_token, last_backfilled:)
1098
1125
  return @svc._fetch_backfill_page(pagination_token, last_backfilled:)
1099
- rescue ::Timeout::Error, ::SocketError
1100
- self.__retryordie
1126
+ rescue ::Timeout::Error, ::SocketError => e
1127
+ self.__retryordie(e)
1101
1128
  rescue Webhookdb::Http::Error => e
1102
- self.__retryordie if e.status >= 500
1129
+ self.__retryordie(e) if e.status >= 500
1103
1130
  raise
1104
1131
  end
1105
1132
 
1106
- def __retryordie
1107
- raise Amigo::Retry::OrDie.new(self.server_error_retries, self.server_error_backoff)
1133
+ def __retryordie(e)
1134
+ raise Amigo::Retry::OrDie.new(self.server_error_retries, self.server_error_backoff, e)
1108
1135
  end
1109
1136
  end
1110
1137
 
@@ -349,6 +349,8 @@ class Webhookdb::Replicator::Column
349
349
 
350
350
  # If provided, use this expression as the UPDATE value when adding the column
351
351
  # to an existing table.
352
+ # To explicitly backfill using NULL, use the value +Sequel[nil]+
353
+ # rather than +nil+.
352
354
  # @return [String,Sequel,Sequel::SQL::Expression]
353
355
  attr_reader :backfill_expr
354
356
 
@@ -409,6 +409,12 @@ class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fa
409
409
  data_key: "my_id",
410
410
  backfill_expr: "hi there",
411
411
  ),
412
+ Webhookdb::Replicator::Column.new(
413
+ :using_null_backfill_expr,
414
+ TEXT,
415
+ data_key: "my_id",
416
+ backfill_expr: Sequel[nil],
417
+ ),
412
418
  Webhookdb::Replicator::Column.new(
413
419
  :using_backfill_statement,
414
420
  TEXT,
@@ -239,35 +239,44 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
239
239
  @signalwire_sint = replicator.service_integration.depends_on
240
240
  end
241
241
 
242
- def handle_item(item)
243
- front_id = item.fetch(:front_message_id)
244
- sw_id = item.fetch(:signalwire_sid)
242
+ def handle_item(db_row)
243
+ front_id = db_row.fetch(:front_message_id)
244
+ sw_id = db_row.fetch(:signalwire_sid)
245
+ # This is sort of gross- we get the db row here, and need to re-update it with certain fields
246
+ # as a result of the signalwire or front sync. To do that, we need to run the upsert on 'data',
247
+ # but what's in 'data' is incomplete. So we use the db row to form a more fully complete 'data'.
248
+ upserting_data = db_row.dup
249
+ # Remove the columns that don't belong in 'data'
250
+ upserting_data.delete(:pk)
251
+ upserting_data.delete(:row_updated_at)
252
+ # Splat the 'data' column into the row so it all gets put back into 'data'
253
+ upserting_data.merge!(**upserting_data.delete(:data))
245
254
  if (front_id && sw_id) || (!front_id && !sw_id)
246
- msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{item}"
255
+ msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{db_row}"
247
256
  raise Webhookdb::InvariantViolation, msg
248
257
  end
249
- sender = @replicator.format_phone(item.fetch(:sender))
250
- recipient = @replicator.format_phone(item.fetch(:recipient))
251
- body = item.fetch(:body)
252
- idempotency_key = "fsmca-fims-#{item.fetch(:external_id)}"
258
+ sender = @replicator.format_phone(db_row.fetch(:sender))
259
+ recipient = @replicator.format_phone(db_row.fetch(:recipient))
260
+ body = db_row.fetch(:body)
261
+ idempotency_key = "fsmca-fims-#{db_row.fetch(:external_id)}"
253
262
  idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
254
263
  if front_id.nil?
255
- texted_at = Time.parse(item.fetch(:data).fetch("date_created"))
264
+ texted_at = Time.parse(db_row.fetch(:data).fetch("date_created"))
256
265
  if texted_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
257
266
  # Do not sync old rows, just mark them synced
258
- item[:front_message_id] = "skipped_due_to_age"
267
+ upserting_data[:front_message_id] = "skipped_due_to_age"
259
268
  else
260
269
  # sync the message into Front
261
270
  front_response_body = idempotency.execute do
262
- self._sync_front_inbound(sender:, texted_at:, item:, body:)
271
+ self._sync_front_inbound(sender:, texted_at:, db_row:, body:)
263
272
  end
264
- item[:front_message_id] = front_response_body.fetch("message_uid")
273
+ upserting_data[:front_message_id] = front_response_body.fetch("message_uid")
265
274
  end
266
275
  else
267
- messaged_at = Time.at(item.fetch(:data).fetch("payload").fetch("created_at"))
276
+ messaged_at = Time.at(db_row.fetch(:data).fetch("payload").fetch("created_at"))
268
277
  if messaged_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
269
278
  # Do not sync old rows, just mark them synced
270
- item[:signalwire_sid] = "skipped_due_to_age"
279
+ upserting_data[:signalwire_sid] = "skipped_due_to_age"
271
280
  else
272
281
  # send the SMS via signalwire
273
282
  signalwire_resp = _send_sms(
@@ -276,10 +285,10 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
276
285
  to: recipient,
277
286
  body:,
278
287
  )
279
- item[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
288
+ upserting_data[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
280
289
  end
281
290
  end
282
- @replicator.upsert_webhook_body(item.deep_stringify_keys)
291
+ @replicator.upsert_webhook_body(upserting_data.deep_stringify_keys)
283
292
  end
284
293
 
285
294
  def _send_sms(idempotency, from:, to:, body:)
@@ -321,14 +330,14 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
321
330
  return nil
322
331
  end
323
332
 
324
- def _sync_front_inbound(sender:, texted_at:, item:, body:)
333
+ def _sync_front_inbound(sender:, texted_at:, db_row:, body:)
325
334
  body = {
326
335
  sender: {handle: sender},
327
336
  body: body || "<no body>",
328
337
  delivered_at: texted_at.to_i,
329
338
  metadata: {
330
- external_id: item.fetch(:external_id),
331
- external_conversation_id: item.fetch(:external_conversation_id),
339
+ external_id: db_row.fetch(:external_id),
340
+ external_conversation_id: db_row.fetch(:external_conversation_id),
332
341
  },
333
342
  }
334
343
  token = JWT.encode(
@@ -74,6 +74,9 @@ The secret to use for signing is:
74
74
  col.new(:row_updated_at, TIMESTAMP, index: true, optional: true, defaulter: :now),
75
75
  col.new(:last_synced_at, TIMESTAMP, index: true, optional: true),
76
76
  col.new(:ics_url, TEXT, converter: col.converter_gsub("^webcal", "https")),
77
+ col.new(:event_count, INTEGER, optional: true),
78
+ col.new(:feed_bytes, INTEGER, optional: true),
79
+ col.new(:last_sync_duration_ms, INTEGER, optional: true),
77
80
  ]
78
81
  end
79
82
 
@@ -166,11 +169,20 @@ The secret to use for signing is:
166
169
  def sync_row(row)
167
170
  Appydays::Loggable.with_log_tags(icalendar_url: row.fetch(:ics_url)) do
168
171
  self.with_advisory_lock(row.fetch(:pk)) do
172
+ start = Time.now
169
173
  now = Time.now
170
174
  if (dep = self.find_dependent("icalendar_event_v1"))
171
- self._sync_row(row, dep, now:)
175
+ processor = self._sync_row(row, dep, now:)
176
+ end
177
+ self.admin_dataset do |ds|
178
+ ds.where(pk: row.fetch(:pk)).
179
+ update(
180
+ last_synced_at: now,
181
+ event_count: processor&.upserted_identities&.count,
182
+ feed_bytes: processor&.read_bytes,
183
+ last_sync_duration_ms: (Time.now - start).in_milliseconds,
184
+ )
172
185
  end
173
- self.admin_dataset { |ds| ds.where(pk: row.fetch(:pk)).update(last_synced_at: now) }
174
186
  end
175
187
  end
176
188
  end
@@ -204,6 +216,7 @@ The secret to use for signing is:
204
216
  row_updated_at: now,
205
217
  )
206
218
  end
219
+ return processor
207
220
  end
208
221
 
209
222
  # We get all sorts of strange urls, fix up what we can.
@@ -224,7 +237,20 @@ The secret to use for signing is:
224
237
  self.logger.info("icalendar_fetch_not_modified", response_status: 304, request_url:, calendar_external_id:)
225
238
  return
226
239
  when Down::SSLError
227
- self._handle_retryable_down_error!(e, request_url:, calendar_external_id:)
240
+ # Most SSL errors are transient and can be retried, but some are due to a long-term misconfiguration.
241
+ # Handle these with an alert, like if we had a 404, which indicates a longer-term issue.
242
+ is_fatal =
243
+ # There doesn't appear to be a way to allow unsafe legacy content negotiation on a per-request basis,
244
+ # it is compiled into OpenSSL (may be wrong about this).
245
+ e.to_s.include?("unsafe legacy renegotiation disabled") ||
246
+ # Certificate failures are not transient
247
+ e.to_s.include?("certificate verify failed")
248
+ if is_fatal
249
+ response_status = 0
250
+ response_body = e.to_s
251
+ else
252
+ self._handle_retryable_down_error!(e, request_url:, calendar_external_id:)
253
+ end
228
254
  when Down::TimeoutError, Down::ConnectionError, Down::InvalidUrl, URI::InvalidURIError
229
255
  response_status = 0
230
256
  response_body = e.to_s
@@ -259,8 +285,9 @@ The secret to use for signing is:
259
285
  response_status = nil
260
286
  end
261
287
  raise e if response_status.nil?
288
+ loggable_body = response_body && response_body[..256]
262
289
  self.logger.warn("icalendar_fetch_error",
263
- response_body:, response_status:, request_url:, calendar_external_id:,)
290
+ response_body: loggable_body, response_status:, request_url:, calendar_external_id:,)
264
291
  message = Webhookdb::Messages::ErrorIcalendarFetch.new(
265
292
  self.service_integration,
266
293
  calendar_external_id,
@@ -269,7 +296,7 @@ The secret to use for signing is:
269
296
  request_url:,
270
297
  request_method: "GET",
271
298
  )
272
- self.service_integration.organization.alerting.dispatch_alert(message)
299
+ self.service_integration.organization.alerting.dispatch_alert(message, separate_connection: false)
273
300
  end
274
301
 
275
302
  def _retryable_client_error?(e, request_url:)
@@ -299,7 +326,7 @@ The secret to use for signing is:
299
326
  end
300
327
 
301
328
  class EventProcessor
302
- attr_reader :upserted_identities
329
+ attr_reader :upserted_identities, :read_bytes
303
330
 
304
331
  def initialize(io, upserter)
305
332
  @io = io
@@ -316,6 +343,9 @@ The secret to use for signing is:
316
343
  # We need to keep track of how many events each UID spawns,
317
344
  # so we can delete any with a higher count.
318
345
  @max_sequence_num_by_uid = {}
346
+ # Keep track of the bytes we've read from the file.
347
+ # Never trust Content-Length headers for ical feeds.
348
+ @read_bytes = 0
319
349
  end
320
350
 
321
351
  def delete_condition
@@ -474,7 +504,11 @@ The secret to use for signing is:
474
504
  def _ical_entry_from_ruby(r, entry, is_date)
475
505
  return {"v" => r.strftime("%Y%m%d")} if is_date
476
506
  return {"v" => r.strftime("%Y%m%dT%H%M%SZ")} if r.zone == "UTC"
477
- return {"v" => r.strftime("%Y%m%dT%H%M%S"), "TZID" => entry.fetch("TZID")}
507
+ tzid = entry["TZID"]
508
+ return {"v" => r.strftime("%Y%m%dT%H%M%S"), "TZID" => tzid} if tzid
509
+ value = entry.fetch("v")
510
+ return {"v" => value} if value.end_with?("Z")
511
+ raise "Cannot create ical entry from: #{r}, #{entry}, is_date: #{is_date}"
478
512
  end
479
513
 
480
514
  def _icecube_rule_from_ical(ical)
@@ -483,11 +517,20 @@ The secret to use for signing is:
483
517
  # IceCube errors, because `day_of_month` isn't valid on a WeeklyRule.
484
518
  # In this case, we need to sanitize the string to remove the offending rule piece.
485
519
  # There are probably many other offending formats, but we'll add them here as needed.
520
+ unambiguous_ical = nil
486
521
  if ical.include?("FREQ=WEEKLY") && ical.include?("BYMONTHDAY=")
487
- ical = ical.gsub(/BYMONTHDAY=[\d,]+/, "")
488
- ical.delete_prefix! ";"
489
- ical.delete_suffix! ";"
490
- ical.squeeze!(";")
522
+ unambiguous_ical = ical.gsub(/BYMONTHDAY=[\d,]+/, "")
523
+ elsif ical.include?("FREQ=MONTHLY") && ical.include?("BYYEARDAY=") && ical.include?("BYMONTHDAY=")
524
+ # Another rule: FREQ=MONTHLY;INTERVAL=3;BYYEARDAY=14;BYMONTHDAY=14
525
+ # Apple interprets this as monthly on the 14th; rrule.js interprets this as never happening.
526
+ # 'day_of_year' isn't valid on a MonthlyRule, so delete the BYYEARDAY component.
527
+ unambiguous_ical = ical.gsub(/BYYEARDAY=[\d,]+/, "")
528
+ end
529
+ if unambiguous_ical
530
+ unambiguous_ical.delete_prefix! ";"
531
+ unambiguous_ical.delete_suffix! ";"
532
+ unambiguous_ical.squeeze!(";")
533
+ ical = unambiguous_ical
491
534
  end
492
535
  return IceCube::IcalParser.rule_from_ical(ical)
493
536
  end
@@ -507,6 +550,7 @@ The secret to use for signing is:
507
550
  vevent_lines = []
508
551
  in_vevent = false
509
552
  while (line = @io.gets)
553
+ @read_bytes += line.size
510
554
  begin
511
555
  line.rstrip!
512
556
  rescue Encoding::CompatibilityError
@@ -22,10 +22,9 @@ module Webhookdb::Replicator::IntercomV1Mixin
22
22
  # webhook verification, which means that webhooks actually don't require any setup on the integration level. Thus,
23
23
  # `supports_webhooks` is false.
24
24
  def find_auth_integration
25
- # rubocop:disable Naming/MemoizedInstanceVariableName
26
- return @auth ||= Webhookdb::Replicator.find_at_root!(self.service_integration,
27
- service_name: "intercom_marketplace_root_v1",)
28
- # rubocop:enable Naming/MemoizedInstanceVariableName
25
+ return @find_auth_integration ||= Webhookdb::Replicator.find_at_root!(
26
+ self.service_integration, service_name: "intercom_marketplace_root_v1",
27
+ )
29
28
  end
30
29
 
31
30
  def intercom_auth_headers
@@ -93,6 +92,28 @@ module Webhookdb::Replicator::IntercomV1Mixin
93
92
  timeout: Webhookdb::Intercom.http_timeout,
94
93
  )
95
94
  rescue Webhookdb::Http::Error => e
95
+ is_token_suspended = e.status == 401 &&
96
+ e.response["errors"].present? &&
97
+ e.response["errors"].any? { |er| er["code"] == "token_suspended" }
98
+ if is_token_suspended
99
+ root_sint = self.find_auth_integration
100
+ message = "Organization has closed their Intercom workspace and this integration should be deleted. " \
101
+ "From a console, run: " \
102
+ "Webhookdb::ServiceIntegration[#{root_sint.id}].destroy_self_and_all_dependents"
103
+ Webhookdb::DeveloperAlert.new(
104
+ subsystem: "Intercom Workspace Closed Error",
105
+ emoji: ":hook:",
106
+ fallback: message,
107
+ fields: [
108
+ {title: "Organization", value: root_sint.organization.name, short: true},
109
+ {title: "Integration ID", value: root_sint.id.to_s, short: true},
110
+ {title: "Instructions", value: message},
111
+ ],
112
+ ).emit
113
+ # Noop here since there's nothing to do, the developer alert takes care of notifying
114
+ # so no need to error or log.
115
+ return [], nil
116
+ end
96
117
  # We are looking to catch the "api plan restricted" error. This is always a 403 and every
97
118
  # 403 will be an "api plan restricted" error according to the API documentation. Because we
98
119
  # specify the API version in our headers we can expect that this won't change.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "webhookdb/errors"
3
4
  require "webhookdb/signalwire"
5
+ require "webhookdb/messages/error_generic_backfill"
4
6
 
5
7
  class Webhookdb::Replicator::SignalwireMessageV1 < Webhookdb::Replicator::Base
6
8
  include Appydays::Loggable
@@ -180,4 +182,33 @@ Press 'Show' next to the newly-created API token, and copy it.)
180
182
 
181
183
  return messages, data["next_page_uri"]
182
184
  end
185
+
186
+ def on_backfill_error(be)
187
+ e = Webhookdb::Errors.find_cause(be) do |ex|
188
+ next true if ex.is_a?(Webhookdb::Http::Error) && ex.status == 401
189
+ next true if ex.is_a?(::SocketError)
190
+ end
191
+ return unless e
192
+ if e.is_a?(::SocketError)
193
+ response_status = 0
194
+ response_body = e.message
195
+ request_url = "<unknown>"
196
+ request_method = "<unknown>"
197
+ else
198
+ response_status = e.status
199
+ response_body = e.body
200
+ request_url = e.uri.to_s
201
+ request_method = e.http_method
202
+ end
203
+ self.logger.warn("signalwire_backfill_error", response_body:, response_status:, request_url:)
204
+ message = Webhookdb::Messages::ErrorGenericBackfill.new(
205
+ self.service_integration,
206
+ response_status:,
207
+ response_body:,
208
+ request_url:,
209
+ request_method:,
210
+ )
211
+ self.service_integration.organization.alerting.dispatch_alert(message)
212
+ return true
213
+ end
183
214
  end