flow_chat 0.6.1 → 0.7.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +44 -0
- data/.gitignore +2 -1
- data/README.md +84 -1229
- data/docs/configuration.md +337 -0
- data/docs/flows.md +320 -0
- data/docs/images/simulator.png +0 -0
- data/docs/instrumentation.md +216 -0
- data/docs/media.md +153 -0
- data/docs/testing.md +475 -0
- data/docs/ussd-setup.md +306 -0
- data/docs/whatsapp-setup.md +162 -0
- data/examples/multi_tenant_whatsapp_controller.rb +9 -37
- data/examples/simulator_controller.rb +9 -18
- data/examples/ussd_controller.rb +32 -38
- data/examples/whatsapp_controller.rb +32 -125
- data/examples/whatsapp_media_examples.rb +68 -336
- data/examples/whatsapp_message_job.rb +5 -3
- data/flow_chat.gemspec +6 -2
- data/lib/flow_chat/base_processor.rb +48 -2
- data/lib/flow_chat/config.rb +5 -0
- data/lib/flow_chat/context.rb +13 -1
- data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
- data/lib/flow_chat/instrumentation/setup.rb +155 -0
- data/lib/flow_chat/instrumentation.rb +70 -0
- data/lib/flow_chat/prompt.rb +20 -20
- data/lib/flow_chat/session/cache_session_store.rb +73 -7
- data/lib/flow_chat/session/middleware.rb +37 -4
- data/lib/flow_chat/session/rails_session_store.rb +36 -1
- data/lib/flow_chat/simulator/controller.rb +6 -6
- data/lib/flow_chat/ussd/gateway/nalo.rb +30 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +33 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
- data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
- data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
- data/lib/flow_chat/ussd/processor.rb +14 -0
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +99 -12
- data/lib/flow_chat/whatsapp/configuration.rb +35 -4
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +120 -34
- data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
- data/lib/flow_chat/whatsapp/processor.rb +8 -0
- data/lib/flow_chat/whatsapp/renderer.rb +4 -9
- data/lib/flow_chat.rb +23 -0
- metadata +22 -11
- data/.travis.yml +0 -6
- data/app/controllers/demo_controller.rb +0 -101
- data/app/flow_chat/demo_restaurant_flow.rb +0 -889
- data/config/routes_demo.rb +0 -59
- data/examples/initializer.rb +0 -86
- data/examples/media_prompts_examples.rb +0 -27
- data/images/ussd_simulator.png +0 -0
@@ -0,0 +1,176 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Instrumentation
|
3
|
+
class LogSubscriber
|
4
|
+
# Flow execution events
|
5
|
+
def flow_execution_start(event)
|
6
|
+
payload = event.payload
|
7
|
+
FlowChat.logger.info { "Flow Execution Started: #{payload[:flow_name]}##{payload[:action]} [Session: #{payload[:session_id]}]" }
|
8
|
+
end
|
9
|
+
|
10
|
+
def flow_execution_end(event)
|
11
|
+
payload = event.payload
|
12
|
+
duration = event.duration.round(2)
|
13
|
+
FlowChat.logger.info { "Flow Execution Completed: #{payload[:flow_name]}##{payload[:action]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
|
14
|
+
end
|
15
|
+
|
16
|
+
def flow_execution_error(event)
|
17
|
+
payload = event.payload
|
18
|
+
duration = event.duration.round(2)
|
19
|
+
FlowChat.logger.error { "Flow Execution Failed: #{payload[:flow_name]}##{payload[:action]} (#{duration}ms) - #{payload[:error_class]}: #{payload[:error_message]} [Session: #{payload[:session_id]}]" }
|
20
|
+
end
|
21
|
+
|
22
|
+
# Session events
|
23
|
+
def session_created(event)
|
24
|
+
payload = event.payload
|
25
|
+
FlowChat.logger.info { "Session Created: #{payload[:session_id]} [Store: #{payload[:store_type]}, Gateway: #{payload[:gateway]}]" }
|
26
|
+
end
|
27
|
+
|
28
|
+
def session_destroyed(event)
|
29
|
+
payload = event.payload
|
30
|
+
FlowChat.logger.info { "Session Destroyed: #{payload[:session_id]} [Gateway: #{payload[:gateway]}]" }
|
31
|
+
end
|
32
|
+
|
33
|
+
def session_cache_hit(event)
|
34
|
+
payload = event.payload
|
35
|
+
FlowChat.logger.debug { "Session Cache Hit: #{payload[:session_id]} - Key: #{payload[:key]}" }
|
36
|
+
end
|
37
|
+
|
38
|
+
def session_cache_miss(event)
|
39
|
+
payload = event.payload
|
40
|
+
FlowChat.logger.debug { "Session Cache Miss: #{payload[:session_id]} - Key: #{payload[:key]}" }
|
41
|
+
end
|
42
|
+
|
43
|
+
def session_data_set(event)
|
44
|
+
payload = event.payload
|
45
|
+
FlowChat.logger.debug { "Session Data Set: #{payload[:session_id]} - Key: #{payload[:key]}" }
|
46
|
+
end
|
47
|
+
|
48
|
+
def session_data_get(event)
|
49
|
+
payload = event.payload
|
50
|
+
FlowChat.logger.debug { "Session Data Get: #{payload[:session_id]} - Key: #{payload[:key]} = #{payload[:value].inspect}" }
|
51
|
+
end
|
52
|
+
|
53
|
+
# Middleware events
|
54
|
+
def middleware_before(event)
|
55
|
+
payload = event.payload
|
56
|
+
FlowChat.logger.debug { "Middleware Before: #{payload[:middleware_name]} [Session: #{payload[:session_id]}]" }
|
57
|
+
end
|
58
|
+
|
59
|
+
def middleware_after(event)
|
60
|
+
payload = event.payload
|
61
|
+
duration = event.duration.round(2)
|
62
|
+
FlowChat.logger.debug { "Middleware After: #{payload[:middleware_name]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
|
63
|
+
end
|
64
|
+
|
65
|
+
# Platform-agnostic events (new scalable approach)
|
66
|
+
def message_received(event)
|
67
|
+
payload = event.payload
|
68
|
+
platform = payload[:platform] || "unknown"
|
69
|
+
platform_name = format_platform_name(platform)
|
70
|
+
|
71
|
+
case platform.to_sym
|
72
|
+
when :whatsapp
|
73
|
+
contact_info = payload[:contact_name] ? " (#{payload[:contact_name]})" : ""
|
74
|
+
FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]}#{contact_info} - Type: #{payload[:message_type]} [ID: #{payload[:message_id]}]" }
|
75
|
+
when :ussd
|
76
|
+
FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]} - Input: '#{payload[:message]}' [Session: #{payload[:session_id]}]" }
|
77
|
+
else
|
78
|
+
FlowChat.logger.info { "#{platform_name} Message Received: #{payload[:from]} - Message: '#{payload[:message]}' [Session: #{payload[:session_id]}]" }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def message_sent(event)
|
83
|
+
payload = event.payload
|
84
|
+
platform = payload[:platform] || "unknown"
|
85
|
+
platform_name = format_platform_name(platform)
|
86
|
+
duration = event.duration.round(2)
|
87
|
+
|
88
|
+
case platform.to_sym
|
89
|
+
when :whatsapp
|
90
|
+
FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms) [Length: #{payload[:content_length]} chars]" }
|
91
|
+
when :ussd
|
92
|
+
FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms) [Session: #{payload[:session_id]}]" }
|
93
|
+
else
|
94
|
+
FlowChat.logger.info { "#{platform_name} Message Sent: #{payload[:to]} - Type: #{payload[:message_type]} (#{duration}ms)" }
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def webhook_verified(event)
|
99
|
+
payload = event.payload
|
100
|
+
platform = payload[:platform] || "unknown"
|
101
|
+
platform_name = format_platform_name(platform)
|
102
|
+
FlowChat.logger.info { "#{platform_name} Webhook Verified Successfully [Challenge: #{payload[:challenge]}]" }
|
103
|
+
end
|
104
|
+
|
105
|
+
def webhook_failed(event)
|
106
|
+
payload = event.payload
|
107
|
+
platform = payload[:platform] || "unknown"
|
108
|
+
platform_name = format_platform_name(platform)
|
109
|
+
FlowChat.logger.warn { "#{platform_name} Webhook Verification Failed: #{payload[:reason]}" }
|
110
|
+
end
|
111
|
+
|
112
|
+
def media_upload(event)
|
113
|
+
payload = event.payload
|
114
|
+
platform = payload[:platform] || "unknown"
|
115
|
+
platform_name = format_platform_name(platform)
|
116
|
+
duration = event.duration.round(2)
|
117
|
+
|
118
|
+
if payload[:success] != false # Check for explicit false, not just falsy
|
119
|
+
FlowChat.logger.info { "#{platform_name} Media Upload: #{payload[:filename]} (#{format_bytes(payload[:size])}, #{duration}ms) - Success" }
|
120
|
+
else
|
121
|
+
FlowChat.logger.error { "#{platform_name} Media Upload Failed: #{payload[:filename]} (#{duration}ms) - #{payload[:error]}" }
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def pagination_triggered(event)
|
126
|
+
payload = event.payload
|
127
|
+
platform = payload[:platform] || "unknown"
|
128
|
+
platform_name = format_platform_name(platform)
|
129
|
+
FlowChat.logger.info { "#{platform_name} Pagination Triggered: Page #{payload[:current_page]}/#{payload[:total_pages]} (#{payload[:content_length]} chars) [Session: #{payload[:session_id]}]" }
|
130
|
+
end
|
131
|
+
|
132
|
+
def api_request(event)
|
133
|
+
payload = event.payload
|
134
|
+
platform = payload[:platform] || "unknown"
|
135
|
+
platform_name = format_platform_name(platform)
|
136
|
+
duration = event.duration.round(2)
|
137
|
+
|
138
|
+
if payload[:success]
|
139
|
+
FlowChat.logger.debug { "#{platform_name} API Request: #{payload[:method]} #{payload[:endpoint]} (#{duration}ms) - Success" }
|
140
|
+
else
|
141
|
+
FlowChat.logger.error { "#{platform_name} API Request: #{payload[:method]} #{payload[:endpoint]} (#{duration}ms) - Failed: #{payload[:status]} #{payload[:error]}" }
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Context events
|
146
|
+
def context_created(event)
|
147
|
+
payload = event.payload
|
148
|
+
FlowChat.logger.debug { "Context Created [Gateway: #{payload[:gateway] || "unknown"}]" }
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
# Format platform name for display
|
154
|
+
def format_platform_name(platform)
|
155
|
+
case platform.to_sym
|
156
|
+
when :whatsapp then "WhatsApp"
|
157
|
+
when :ussd then "USSD"
|
158
|
+
else platform.to_s.capitalize
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Format bytes in a human-readable way
|
163
|
+
def format_bytes(bytes)
|
164
|
+
return "unknown size" unless bytes
|
165
|
+
|
166
|
+
if bytes < 1024
|
167
|
+
"#{bytes} bytes"
|
168
|
+
elsif bytes < 1024 * 1024
|
169
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
170
|
+
else
|
171
|
+
"#{(bytes / (1024.0 * 1024.0)).round(1)} MB"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Instrumentation
|
3
|
+
class MetricsCollector
|
4
|
+
attr_reader :metrics
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@metrics = {}
|
8
|
+
@mutex = Mutex.new
|
9
|
+
subscribe_to_events
|
10
|
+
end
|
11
|
+
|
12
|
+
# Get current metrics snapshot
|
13
|
+
def snapshot
|
14
|
+
@mutex.synchronize { @metrics.dup }
|
15
|
+
end
|
16
|
+
|
17
|
+
# Reset all metrics
|
18
|
+
def reset!
|
19
|
+
@mutex.synchronize { @metrics.clear }
|
20
|
+
end
|
21
|
+
|
22
|
+
# Get metrics for a specific category
|
23
|
+
def get_category(category)
|
24
|
+
@mutex.synchronize do
|
25
|
+
@metrics.select { |key, _| key.to_s.start_with?("#{category}.") }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def subscribe_to_events
|
32
|
+
# Flow execution metrics
|
33
|
+
ActiveSupport::Notifications.subscribe("flow.execution.end.flow_chat") do |event|
|
34
|
+
increment_counter("flows.executed")
|
35
|
+
track_timing("flows.execution_time", event.duration)
|
36
|
+
increment_counter("flows.by_name.#{event.payload[:flow_name]}")
|
37
|
+
end
|
38
|
+
|
39
|
+
ActiveSupport::Notifications.subscribe("flow.execution.error.flow_chat") do |event|
|
40
|
+
increment_counter("flows.errors")
|
41
|
+
increment_counter("flows.errors.by_class.#{event.payload[:error_class]}")
|
42
|
+
increment_counter("flows.errors.by_flow.#{event.payload[:flow_name]}")
|
43
|
+
end
|
44
|
+
|
45
|
+
# Session metrics
|
46
|
+
ActiveSupport::Notifications.subscribe("session.created.flow_chat") do |event|
|
47
|
+
increment_counter("sessions.created")
|
48
|
+
increment_counter("sessions.created.by_gateway.#{event.payload[:gateway]}")
|
49
|
+
end
|
50
|
+
|
51
|
+
ActiveSupport::Notifications.subscribe("session.destroyed.flow_chat") do |event|
|
52
|
+
increment_counter("sessions.destroyed")
|
53
|
+
end
|
54
|
+
|
55
|
+
ActiveSupport::Notifications.subscribe("session.cache.hit.flow_chat") do |event|
|
56
|
+
increment_counter("sessions.cache.hits")
|
57
|
+
end
|
58
|
+
|
59
|
+
ActiveSupport::Notifications.subscribe("session.cache.miss.flow_chat") do |event|
|
60
|
+
increment_counter("sessions.cache.misses")
|
61
|
+
end
|
62
|
+
|
63
|
+
ActiveSupport::Notifications.subscribe("session.data.get.flow_chat") do |event|
|
64
|
+
increment_counter("sessions.data.get")
|
65
|
+
end
|
66
|
+
|
67
|
+
ActiveSupport::Notifications.subscribe("session.data.set.flow_chat") do |event|
|
68
|
+
increment_counter("sessions.data.set")
|
69
|
+
end
|
70
|
+
|
71
|
+
ActiveSupport::Notifications.subscribe("message.received.flow_chat") do |event|
|
72
|
+
platform = event.payload[:platform] || "unknown"
|
73
|
+
increment_counter("#{platform}.messages.received")
|
74
|
+
if event.payload[:message_type]
|
75
|
+
increment_counter("#{platform}.messages.received.by_type.#{event.payload[:message_type]}")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
ActiveSupport::Notifications.subscribe("message.sent.flow_chat") do |event|
|
80
|
+
platform = event.payload[:platform] || "unknown"
|
81
|
+
increment_counter("#{platform}.messages.sent")
|
82
|
+
if event.payload[:message_type]
|
83
|
+
increment_counter("#{platform}.messages.sent.by_type.#{event.payload[:message_type]}")
|
84
|
+
end
|
85
|
+
track_timing("#{platform}.api.response_time", event.duration)
|
86
|
+
end
|
87
|
+
|
88
|
+
ActiveSupport::Notifications.subscribe("webhook.verified.flow_chat") do |event|
|
89
|
+
platform = event.payload[:platform] || "unknown"
|
90
|
+
increment_counter("#{platform}.webhook.verified")
|
91
|
+
end
|
92
|
+
|
93
|
+
ActiveSupport::Notifications.subscribe("webhook.failed.flow_chat") do |event|
|
94
|
+
platform = event.payload[:platform] || "unknown"
|
95
|
+
increment_counter("#{platform}.webhook.failures")
|
96
|
+
if event.payload[:reason]
|
97
|
+
increment_counter("#{platform}.webhook.failures.by_reason.#{event.payload[:reason]}")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
ActiveSupport::Notifications.subscribe("media.upload.flow_chat") do |event|
|
102
|
+
platform = event.payload[:platform] || "unknown"
|
103
|
+
if event.payload[:success] != false
|
104
|
+
increment_counter("#{platform}.media.uploads.success")
|
105
|
+
if event.payload[:size]
|
106
|
+
track_histogram("#{platform}.media.upload_size", event.payload[:size])
|
107
|
+
end
|
108
|
+
else
|
109
|
+
increment_counter("#{platform}.media.uploads.failure")
|
110
|
+
end
|
111
|
+
track_timing("#{platform}.media.upload_time", event.duration)
|
112
|
+
end
|
113
|
+
|
114
|
+
ActiveSupport::Notifications.subscribe("pagination.triggered.flow_chat") do |event|
|
115
|
+
platform = event.payload[:platform] || "unknown"
|
116
|
+
increment_counter("#{platform}.pagination.triggered")
|
117
|
+
if event.payload[:content_length]
|
118
|
+
track_histogram("#{platform}.pagination.content_length", event.payload[:content_length])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
ActiveSupport::Notifications.subscribe("api.request.flow_chat") do |event|
|
123
|
+
platform = event.payload[:platform] || "unknown"
|
124
|
+
if event.payload[:success]
|
125
|
+
increment_counter("#{platform}.api.requests.success")
|
126
|
+
else
|
127
|
+
increment_counter("#{platform}.api.requests.failure")
|
128
|
+
if event.payload[:status]
|
129
|
+
increment_counter("#{platform}.api.requests.failure.by_status.#{event.payload[:status]}")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
track_timing("#{platform}.api.request_time", event.duration)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def increment_counter(key, value = 1)
|
137
|
+
@mutex.synchronize do
|
138
|
+
@metrics[key] ||= 0
|
139
|
+
@metrics[key] += value
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def track_timing(key, duration_ms)
|
144
|
+
timing_key = "#{key}.timings"
|
145
|
+
@mutex.synchronize do
|
146
|
+
@metrics[timing_key] ||= []
|
147
|
+
@metrics[timing_key] << duration_ms
|
148
|
+
|
149
|
+
# Keep only last 1000 measurements for memory efficiency
|
150
|
+
@metrics[timing_key] = @metrics[timing_key].last(1000) if @metrics[timing_key].size > 1000
|
151
|
+
|
152
|
+
# Calculate and store aggregates
|
153
|
+
timings = @metrics[timing_key]
|
154
|
+
@metrics["#{key}.avg"] = timings.sum / timings.size
|
155
|
+
@metrics["#{key}.min"] = timings.min
|
156
|
+
@metrics["#{key}.max"] = timings.max
|
157
|
+
@metrics["#{key}.p50"] = percentile(timings, 50)
|
158
|
+
@metrics["#{key}.p95"] = percentile(timings, 95)
|
159
|
+
@metrics["#{key}.p99"] = percentile(timings, 99)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def track_histogram(key, value)
|
164
|
+
histogram_key = "#{key}.histogram"
|
165
|
+
@mutex.synchronize do
|
166
|
+
@metrics[histogram_key] ||= []
|
167
|
+
@metrics[histogram_key] << value
|
168
|
+
|
169
|
+
# Keep only last 1000 measurements
|
170
|
+
@metrics[histogram_key] = @metrics[histogram_key].last(1000) if @metrics[histogram_key].size > 1000
|
171
|
+
|
172
|
+
# Calculate aggregates
|
173
|
+
values = @metrics[histogram_key]
|
174
|
+
@metrics["#{key}.total"] = values.sum
|
175
|
+
@metrics["#{key}.avg"] = values.sum / values.size
|
176
|
+
@metrics["#{key}.min"] = values.min
|
177
|
+
@metrics["#{key}.max"] = values.max
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def percentile(array, percentile)
|
182
|
+
return nil if array.empty?
|
183
|
+
|
184
|
+
sorted = array.sort
|
185
|
+
k = (percentile / 100.0) * (sorted.length - 1)
|
186
|
+
f = k.floor
|
187
|
+
c = k.ceil
|
188
|
+
|
189
|
+
return sorted[k] if f == c
|
190
|
+
|
191
|
+
d0 = sorted[f] * (c - k)
|
192
|
+
d1 = sorted[c] * (k - f)
|
193
|
+
d0 + d1
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
module FlowChat
|
2
|
+
module Instrumentation
|
3
|
+
module Setup
|
4
|
+
class << self
|
5
|
+
attr_accessor :log_subscriber, :metrics_collector
|
6
|
+
|
7
|
+
# Initialize instrumentation with default subscribers
|
8
|
+
def initialize!
|
9
|
+
setup_log_subscriber if FlowChat::Config.logger
|
10
|
+
setup_metrics_collector
|
11
|
+
|
12
|
+
FlowChat.logger&.info { "FlowChat::Instrumentation: Initialized with logging and metrics collection" }
|
13
|
+
end
|
14
|
+
|
15
|
+
# Set up both logging and metrics collection
|
16
|
+
def setup_instrumentation!(options = {})
|
17
|
+
setup_logging!(options)
|
18
|
+
setup_metrics!(options)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Set up logging (LogSubscriber)
|
22
|
+
def setup_logging!(options = {})
|
23
|
+
return if @log_subscriber_setup
|
24
|
+
|
25
|
+
require_relative "log_subscriber"
|
26
|
+
setup_log_subscriber(options)
|
27
|
+
@log_subscriber_setup = true
|
28
|
+
end
|
29
|
+
|
30
|
+
# Set up metrics collection (MetricsCollector)
|
31
|
+
def setup_metrics!(options = {})
|
32
|
+
return if @metrics_collector_setup
|
33
|
+
|
34
|
+
require_relative "metrics_collector"
|
35
|
+
setup_metrics_collector(options)
|
36
|
+
@metrics_collector_setup = true
|
37
|
+
end
|
38
|
+
|
39
|
+
# Cleanup all subscribers
|
40
|
+
def cleanup!
|
41
|
+
@log_subscriber = nil
|
42
|
+
@metrics_collector = nil
|
43
|
+
|
44
|
+
# Note: ActiveSupport::Notifications doesn't provide an easy way to
|
45
|
+
# unsubscribe all subscribers, so this is mainly for reference cleanup
|
46
|
+
FlowChat.logger&.info { "FlowChat::Instrumentation: Cleaned up instrumentation" }
|
47
|
+
end
|
48
|
+
|
49
|
+
# Get current metrics (thread-safe)
|
50
|
+
def metrics
|
51
|
+
@metrics_collector&.snapshot || {}
|
52
|
+
end
|
53
|
+
|
54
|
+
# Reset metrics
|
55
|
+
def reset_metrics!
|
56
|
+
@metrics_collector&.reset!
|
57
|
+
end
|
58
|
+
|
59
|
+
# Subscribe to custom events
|
60
|
+
def subscribe(event_pattern, &block)
|
61
|
+
ActiveSupport::Notifications.subscribe(event_pattern, &block)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Instrument a one-off event
|
65
|
+
def instrument(event_name, payload = {}, &block)
|
66
|
+
full_event_name = "#{event_name}.flow_chat"
|
67
|
+
|
68
|
+
enriched_payload = {
|
69
|
+
timestamp: Time.current
|
70
|
+
}.merge(payload).compact
|
71
|
+
|
72
|
+
ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
|
73
|
+
end
|
74
|
+
|
75
|
+
# Access the metrics collector instance
|
76
|
+
def metrics_collector
|
77
|
+
@metrics_collector ||= FlowChat::Instrumentation::MetricsCollector.new
|
78
|
+
end
|
79
|
+
|
80
|
+
# Reset instrumentation (useful for testing)
|
81
|
+
def reset!
|
82
|
+
@log_subscriber_setup = false
|
83
|
+
@metrics_collector_setup = false
|
84
|
+
@log_subscriber = nil
|
85
|
+
@metrics_collector = nil
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def setup_log_subscriber(options = {})
|
91
|
+
# Check if Rails is available and use its initialization callback
|
92
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
93
|
+
Rails.application.config.after_initialize do
|
94
|
+
initialize_log_subscriber
|
95
|
+
end
|
96
|
+
else
|
97
|
+
# Initialize immediately for non-Rails environments
|
98
|
+
initialize_log_subscriber
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def initialize_log_subscriber
|
103
|
+
return if @log_subscriber
|
104
|
+
|
105
|
+
@log_subscriber = FlowChat::Instrumentation::LogSubscriber.new
|
106
|
+
|
107
|
+
# Manually subscribe to all FlowChat events
|
108
|
+
subscribe_to_events
|
109
|
+
end
|
110
|
+
|
111
|
+
def subscribe_to_events
|
112
|
+
# Core framework events
|
113
|
+
subscribe_event("flow.execution.start.flow_chat", :flow_execution_start)
|
114
|
+
subscribe_event("flow.execution.end.flow_chat", :flow_execution_end)
|
115
|
+
subscribe_event("flow.execution.error.flow_chat", :flow_execution_error)
|
116
|
+
|
117
|
+
# Session events
|
118
|
+
subscribe_event("session.created.flow_chat", :session_created)
|
119
|
+
subscribe_event("session.destroyed.flow_chat", :session_destroyed)
|
120
|
+
subscribe_event("session.data.get.flow_chat", :session_data_get)
|
121
|
+
subscribe_event("session.data.set.flow_chat", :session_data_set)
|
122
|
+
subscribe_event("session.cache.hit.flow_chat", :session_cache_hit)
|
123
|
+
subscribe_event("session.cache.miss.flow_chat", :session_cache_miss)
|
124
|
+
|
125
|
+
# Platform-agnostic events (new scalable approach)
|
126
|
+
subscribe_event("message.received.flow_chat", :message_received)
|
127
|
+
subscribe_event("message.sent.flow_chat", :message_sent)
|
128
|
+
subscribe_event("webhook.verified.flow_chat", :webhook_verified)
|
129
|
+
subscribe_event("webhook.failed.flow_chat", :webhook_failed)
|
130
|
+
subscribe_event("api.request.flow_chat", :api_request)
|
131
|
+
subscribe_event("media.upload.flow_chat", :media_upload)
|
132
|
+
subscribe_event("pagination.triggered.flow_chat", :pagination_triggered)
|
133
|
+
|
134
|
+
# Middleware events
|
135
|
+
subscribe_event("middleware.before.flow_chat", :middleware_before)
|
136
|
+
subscribe_event("middleware.after.flow_chat", :middleware_after)
|
137
|
+
|
138
|
+
# Context events
|
139
|
+
subscribe_event("context.created.flow_chat", :context_created)
|
140
|
+
end
|
141
|
+
|
142
|
+
def subscribe_event(event_name, method_name)
|
143
|
+
ActiveSupport::Notifications.subscribe(event_name) do |*args|
|
144
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
145
|
+
@log_subscriber.send(method_name, event) if @log_subscriber.respond_to?(method_name)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def setup_metrics_collector(options = {})
|
150
|
+
@metrics_collector = FlowChat::Instrumentation::MetricsCollector.new
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require "active_support/notifications"
|
2
|
+
|
3
|
+
module FlowChat
|
4
|
+
module Instrumentation
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
# Instrument a block of code with the given event name and payload
|
8
|
+
def instrument(event_name, payload = {}, &block)
|
9
|
+
enriched_payload = payload&.dup || {}
|
10
|
+
if respond_to?(:context) && context
|
11
|
+
enriched_payload[:session_id] = context["session.id"] if context["session.id"]
|
12
|
+
enriched_payload[:flow_name] = context["flow.name"] if context["flow.name"]
|
13
|
+
enriched_payload[:gateway] = context["request.gateway"] if context["request.gateway"]
|
14
|
+
end
|
15
|
+
|
16
|
+
self.class.instrument(event_name, enriched_payload, &block)
|
17
|
+
end
|
18
|
+
|
19
|
+
class_methods do
|
20
|
+
def instrument(event_name, payload = {}, &block)
|
21
|
+
FlowChat::Instrumentation.instrument(event_name, payload, &block)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Module-level method for direct calls like FlowChat::Instrumentation.instrument
|
26
|
+
def self.instrument(event_name, payload = {}, &block)
|
27
|
+
full_event_name = "#{event_name}.flow_chat"
|
28
|
+
|
29
|
+
enriched_payload = {
|
30
|
+
timestamp: Time.current
|
31
|
+
}.merge(payload || {}).compact
|
32
|
+
|
33
|
+
ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Predefined event names for consistency
|
37
|
+
module Events
|
38
|
+
# Core framework events
|
39
|
+
FLOW_EXECUTION_START = "flow.execution.start"
|
40
|
+
FLOW_EXECUTION_END = "flow.execution.end"
|
41
|
+
FLOW_EXECUTION_ERROR = "flow.execution.error"
|
42
|
+
|
43
|
+
# Context events
|
44
|
+
CONTEXT_CREATED = "context.created"
|
45
|
+
|
46
|
+
# Session events
|
47
|
+
SESSION_CREATED = "session.created"
|
48
|
+
SESSION_DESTROYED = "session.destroyed"
|
49
|
+
SESSION_DATA_GET = "session.data.get"
|
50
|
+
SESSION_DATA_SET = "session.data.set"
|
51
|
+
SESSION_CACHE_HIT = "session.cache.hit"
|
52
|
+
SESSION_CACHE_MISS = "session.cache.miss"
|
53
|
+
|
54
|
+
# Platform-agnostic messaging events
|
55
|
+
# Gateway/platform information is included in the payload
|
56
|
+
MESSAGE_RECEIVED = "message.received"
|
57
|
+
MESSAGE_SENT = "message.sent"
|
58
|
+
WEBHOOK_VERIFIED = "webhook.verified"
|
59
|
+
WEBHOOK_FAILED = "webhook.failed"
|
60
|
+
API_REQUEST = "api.request"
|
61
|
+
MEDIA_UPLOAD = "media.upload"
|
62
|
+
|
63
|
+
PAGINATION_TRIGGERED = "pagination.triggered"
|
64
|
+
|
65
|
+
# Middleware events
|
66
|
+
MIDDLEWARE_BEFORE = "middleware.before"
|
67
|
+
MIDDLEWARE_AFTER = "middleware.after"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/flow_chat/prompt.rb
CHANGED
@@ -6,13 +6,9 @@ module FlowChat
|
|
6
6
|
@user_input = input
|
7
7
|
end
|
8
8
|
|
9
|
-
def ask(msg, choices: nil,
|
10
|
-
# Validate media and choices compatibility
|
11
|
-
validate_media_choices_compatibility(media, choices)
|
12
|
-
|
9
|
+
def ask(msg, choices: nil, transform: nil, validate: nil, media: nil)
|
13
10
|
if user_input.present?
|
14
11
|
input = user_input
|
15
|
-
input = convert.call(input) if convert.present?
|
16
12
|
validation_error = validate.call(input) if validate.present?
|
17
13
|
|
18
14
|
if validation_error.present?
|
@@ -38,17 +34,18 @@ module FlowChat
|
|
38
34
|
terminate! message, media: media
|
39
35
|
end
|
40
36
|
|
41
|
-
def select(msg, choices, media: nil)
|
42
|
-
|
43
|
-
validate_media_choices_compatibility(media, choices)
|
37
|
+
def select(msg, choices, media: nil, error_message: "Invalid selection:")
|
38
|
+
raise ArgumentError, "choices must be an array or hash" unless choices.is_a?(Array) || choices.is_a?(Hash)
|
44
39
|
|
45
|
-
|
40
|
+
normalized_choices = normalize_choices(choices)
|
46
41
|
ask(
|
47
42
|
msg,
|
48
|
-
choices:
|
49
|
-
|
50
|
-
|
51
|
-
|
43
|
+
choices: choices,
|
44
|
+
validate: lambda { |choice| error_message unless normalized_choices.key?(choice.to_s) },
|
45
|
+
transform: lambda do |choice|
|
46
|
+
choices = choices.keys if choices.is_a?(Hash)
|
47
|
+
choices.index_by { |choice| choice.to_s }[choice.to_s]
|
48
|
+
end,
|
52
49
|
media: media
|
53
50
|
)
|
54
51
|
end
|
@@ -67,20 +64,23 @@ module FlowChat
|
|
67
64
|
end
|
68
65
|
end
|
69
66
|
|
70
|
-
def
|
67
|
+
def normalize_choices(choices)
|
71
68
|
case choices
|
72
|
-
when
|
73
|
-
|
69
|
+
when nil
|
70
|
+
nil
|
74
71
|
when Hash
|
75
|
-
|
76
|
-
|
72
|
+
choices.map { |k, v| [k.to_s, v] }.to_h
|
73
|
+
when Array
|
74
|
+
choices.map { |c| [c.to_s, c] }.to_h
|
77
75
|
else
|
78
76
|
raise ArgumentError, "choices must be an array or hash"
|
79
77
|
end
|
80
|
-
[choices, choices_prompt]
|
81
78
|
end
|
82
79
|
|
83
80
|
def prompt!(msg, choices: nil, media: nil)
|
81
|
+
validate_media_choices_compatibility(media, choices)
|
82
|
+
|
83
|
+
choices = normalize_choices(choices)
|
84
84
|
raise FlowChat::Interrupt::Prompt.new(msg, choices: choices, media: media)
|
85
85
|
end
|
86
86
|
|
@@ -88,4 +88,4 @@ module FlowChat
|
|
88
88
|
raise FlowChat::Interrupt::Terminate.new(msg, media: media)
|
89
89
|
end
|
90
90
|
end
|
91
|
-
end
|
91
|
+
end
|