Module: FlowChat::Instrumentation

Extended by:
ActiveSupport::Concern
Included in:
Context, Http::Gateway::Simple, FlowChat::Intercom::Client, FlowChat::Intercom::Gateway::IntercomApi, Messenger::Client, Meta::MessagingGateway, Processor, Session::CacheSessionStore, Session::Middleware, Telegram::Client, Telegram::Gateway::BotApi, Ussd::Gateway::Nalo, Ussd::Middleware::Pagination, Whatsapp::Client, Whatsapp::Gateway::CloudApi
Defined in:
lib/flow_chat/instrumentation.rb,
lib/flow_chat/instrumentation/setup.rb,
lib/flow_chat/instrumentation/log_subscriber.rb,
lib/flow_chat/instrumentation/metrics_collector.rb

Defined Under Namespace

Modules: Events, Setup Classes: LogSubscriber, MetricsCollector

Constant Summary collapse

DELIVERED_MESSAGE_ID_KEY =

Where a delivered reply's platform message id is left on the context, the same way for every gateway. nil when the platform does not name one.

"delivery.platform_message_id"
DELIVERY_DURATION_KEY =

How long the send itself took, in milliseconds, left here by report_delivery_failure for the gateway to put on MESSAGE_SENT.

Measured rather than taken from ActiveSupport::Notifications' own event duration: a block event is published whatever the block returns, so timing the send that way meant publishing MESSAGE_SENT for sends that failed. The event is emitted after the fact instead, which leaves its own duration at zero, so the real figure is carried in the payload.

"delivery.duration_ms"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.instrument(event_name, payload = {}, &block) ⇒ Object

Module-level method for direct calls like FlowChat::Instrumentation.instrument



158
159
160
161
162
163
164
165
166
# File 'lib/flow_chat/instrumentation.rb', line 158

def self.instrument(event_name, payload = {}, &block)
  full_event_name = "#{event_name}.flow_chat"

  enriched_payload = {
    timestamp: Time.current
  }.merge(payload || {}).compact

  ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
end

.report_api_error(message, error: nil, **context) ⇒ Object

Shared helper for reporting API errors with instrumentation and Rails.error

message is prose for a human reading logs. Subscribers deciding what to do about an error should read the structured keys instead, so that rewording a message never changes behaviour somewhere else:

error_class  the exception's class, filled in here from `error`
error_type   what kind of failure it is, named by the adapter
error_code   the platform's own code, where it gives one

Parameters:

  • message (String)

    Human readable description, for logs

  • error (Exception, nil) (defaults to: nil)

    Original exception if available

  • context (Hash)

    Platform-specific error context (must include :platform)



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/flow_chat/instrumentation.rb', line 181

def self.report_api_error(message, error: nil, **context)
  error_context = context.compact

  # An exception's class is a classification the caller already made. Carry
  # it so a subscriber can branch on it rather than parsing the message.
  error_context[:error_class] ||= error.class.name if error

  # Instrument for custom subscribers
  instrument(Events::API_ERROR, error_context.merge(message: message))

  # Report to Rails.error if available
  if defined?(Rails) && Rails.respond_to?(:error) && Rails.error.respond_to?(:report)
    exception = error || StandardError.new(message)
    Rails.error.report(exception, handled: true, context: error_context)
  end
end

Instance Method Details

#elapsed_ms_since(started_at) ⇒ Object



89
90
91
# File 'lib/flow_chat/instrumentation.rb', line 89

def elapsed_ms_since(started_at)
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
end

#inbound_message?(context) ⇒ Boolean

True when this turn carries something to process — text OR a structured attachment (media/location/contact). Gateways gate MESSAGE_RECEIVED on this so caption-less media, locations, and contacts are still instrumented: they set a blank input string (not the old "$media$"-style sentinel), so a plain context.input.present? check would silently drop them.

Returns:

  • (Boolean)


142
143
144
145
146
147
148
149
# File 'lib/flow_chat/instrumentation.rb', line 142

def inbound_message?(context)
  return false unless context

  context.input.present? ||
    !context["request.media"].nil? ||
    !context["request.location"].nil? ||
    !context["request.contact"].nil?
end

#instrument(event_name, payload = {}, &block) ⇒ Object

Instrument a block of code with the given event name and payload



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/flow_chat/instrumentation.rb', line 22

def instrument(event_name, payload = {}, &block)
  enriched_payload = payload&.dup || {}
  if respond_to?(:context) && context
    enriched_payload[:request_id] = context["request.id"] if context["request.id"]
    enriched_payload[:session_id] = context["session.id"] if context["session.id"]
    enriched_payload[:flow_name] = context["flow.name"] if context["flow.name"]
    enriched_payload[:gateway] = context["request.gateway"] if context["request.gateway"]
    enriched_payload[:platform] = context["request.platform"] if context["request.platform"]
  end

  self.class.instrument(event_name, enriched_payload, &block)
end

#platform_message_id_from(result) ⇒ Object

What the platform called the message it just accepted.

Overridden by every gateway that delivers out of band, because each one is the only thing that knows the shape of its own client's answer. Naming it here rather than in each app is the point: an app stamping the id onto its own record should not have to carry a case statement over platforms.



110
111
112
# File 'lib/flow_chat/instrumentation.rb', line 110

def platform_message_id_from(result)
  nil
end

#report_delivery_failure(context, **payload) ⇒ Object

Wraps a delivery so a reply the platform would not take is reported.

A gateway sends after the middleware stack has returned. An app that records what the flow said has therefore already recorded it, and recorded it as having gone out, before anything knows whether it did. The send is the only place that learns otherwise, and it is downstream of everything that could act on it.

Reported two ways, because two different kinds of reader want it.

The event is a broadcast, and takes the same shape its gateway gives MESSAGE_SENT: what was being sent and where, and nothing else. Anyone may subscribe, including tools that write whatever they are handed straight into a log, so it carries no more than the send itself already announces.

The callback is the app that owns this turn, acting on records only it knows about. It gets the whole context because it is the app's own code, configured by the app, and reading what the app put there. That is not true of a subscriber, and the context holds the gateway client and the raw inbound body.

Re-raises whatever the send raised: this reports a failure, it does not handle one. A send fails two ways and only one of them raises. Every client here answers with the platform's parsed response when the message was accepted and nil once it has already logged an API error, so a nil result is a failure that arrived quietly. Treating it as success fired on_delivery_success for a message that was never delivered, and stamped a nil id onto the context as though the platform had named one.

It reports rather than raises, because the client already decided not to: turning a swallowed API error into an exception here would fail the webhook for a reply the platform merely declined.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/flow_chat/instrumentation.rb', line 68

def report_delivery_failure(context, **payload)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = yield
  context[DELIVERY_DURATION_KEY] = elapsed_ms_since(started_at)

  if result.nil?
    error = FlowChat::DeliveryError.new("#{payload[:platform] || "the platform"} did not accept the message")
    report_to_subscribers(error, payload)
    report_to_app(context, error)
    return nil
  end

  report_delivery_success(context, result)
  result
rescue => error
  context[DELIVERY_DURATION_KEY] ||= elapsed_ms_since(started_at) if started_at
  report_to_subscribers(error, payload)
  report_to_app(context, error)
  raise error
end

#report_delivery_success(context, result) ⇒ Object

The success half. Runs where the send happened, which is the only place that knows what the platform called the message.



95
96
97
98
99
100
101
102
# File 'lib/flow_chat/instrumentation.rb', line 95

def report_delivery_success(context, result)
  context[DELIVERED_MESSAGE_ID_KEY] = platform_message_id_from(result)
  FlowChat::Config.on_delivery_success&.call(context, result)
rescue => callback_error
  FlowChat.logger.error do
    "Instrumentation: on_delivery_success raised #{callback_error.class}: #{callback_error.message}"
  end
end

#report_to_app(context, error) ⇒ Object



129
130
131
132
133
134
135
# File 'lib/flow_chat/instrumentation.rb', line 129

def report_to_app(context, error)
  FlowChat::Config.on_delivery_failure&.call(context, error)
rescue => callback_error
  FlowChat.logger.error do
    "Instrumentation: on_delivery_failure raised #{callback_error.class}: #{callback_error.message}"
  end
end

#report_to_subscribers(error, payload) ⇒ Object

Neither reader may replace the delivery error with one of its own, which would hide the failure they are being told about. Notifications gather subscriber errors and re-raise them, so both are reachable.



117
118
119
120
121
122
123
124
125
126
127
# File 'lib/flow_chat/instrumentation.rb', line 117

def report_to_subscribers(error, payload)
  instrument(Events::MESSAGE_DELIVERY_FAILED, payload.merge(
    error_class: error.class.name,
    message: error.message
  ))
rescue => subscriber_error
  FlowChat.logger.error do
    "Instrumentation: a #{Events::MESSAGE_DELIVERY_FAILED} subscriber raised " \
      "#{subscriber_error.class}: #{subscriber_error.message}"
  end
end