coolhand 0.2.0 → 0.3.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.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+
5
+ module Coolhand
6
+ class LoggerService < ApiService
7
+ def initialize
8
+ super("v2/llm_request_logs")
9
+ end
10
+
11
+ def log_to_api(captured_data)
12
+ create_log(captured_data, "auto-monitor")
13
+ end
14
+
15
+ # Helper method for forwarding webhook data to Coolhand
16
+ def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
17
+ # Validate required parameters
18
+ unless Coolhand.required_field?(webhook_body)
19
+ error_msg = "webhook_body is required and cannot be nil or empty"
20
+ if Coolhand.configuration.silent
21
+ puts "COOLHAND WARNING: #{error_msg}"
22
+ return false
23
+ else
24
+ raise ArgumentError, error_msg
25
+ end
26
+ end
27
+
28
+ unless Coolhand.required_field?(source)
29
+ error_msg = "source is required and cannot be nil or empty"
30
+ if Coolhand.configuration.silent
31
+ puts "COOLHAND WARNING: #{error_msg}"
32
+ return false
33
+ else
34
+ raise ArgumentError, error_msg
35
+ end
36
+ end
37
+
38
+ # Auto-generate required fields unless provided
39
+ webhook_data = {
40
+ id: options[:id] || SecureRandom.uuid,
41
+ timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
42
+ method: options[:method] || "POST",
43
+ url: options[:url] || build_webhook_url(source, event_type),
44
+ headers: sanitize_headers(headers),
45
+ request_body: clean_webhook_body(webhook_body, source),
46
+ response_body: options[:response_body],
47
+ response_headers: options[:response_headers],
48
+ status_code: options[:status_code] || 200,
49
+ source: "#{source}_webhook"
50
+ }.merge(options.slice(:metadata, :conversation_id, :agent_id))
51
+
52
+ # Send to API asynchronously
53
+ log_to_api(webhook_data)
54
+ end
55
+
56
+ private
57
+
58
+ def build_webhook_url(source, event_type)
59
+ base = "webhook://#{source}"
60
+ event_type ? "#{base}/#{event_type}" : base
61
+ end
62
+
63
+ def sanitize_headers(headers)
64
+ return {} if headers.nil?
65
+ return {} if headers.respond_to?(:empty?) && headers.empty?
66
+ return {} unless headers.respond_to?(:each)
67
+
68
+ # Handle Rails request headers or plain hash
69
+ clean_headers = {}
70
+ headers.each do |key, value|
71
+ next unless key && value
72
+
73
+ # Convert Rails HTTP_ prefix headers
74
+ clean_key = key.to_s.gsub(/^HTTP_/, "").tr("_", "-").downcase
75
+
76
+ # Redact sensitive headers
77
+ clean_value = clean_key.match?(/key|token|secret|authorization|signature/i) ? "[REDACTED]" : value.to_s
78
+ clean_headers[clean_key] = clean_value
79
+ end
80
+ clean_headers
81
+ end
82
+
83
+ def clean_webhook_body(body, source)
84
+ # Service-specific binary field filters
85
+ binary_fields = case source.to_s.downcase
86
+ when "elevenlabs"
87
+ %w[full_audio audio audio_data raw_audio audio_base64 voice_sample audio_url]
88
+ when "twilio"
89
+ %w[recording_url media_url]
90
+ else
91
+ %w[audio_data image_data file_content binary_content]
92
+ end
93
+
94
+ remove_binary_fields(body, binary_fields)
95
+ end
96
+
97
+ def remove_binary_fields(obj, fields_to_remove)
98
+ case obj
99
+ when Hash
100
+ obj.each_with_object({}) do |(key, value), cleaned|
101
+ next if fields_to_remove.any? { |field| key.to_s.downcase.include?(field) }
102
+
103
+ cleaned[key] = remove_binary_fields(value, fields_to_remove)
104
+ end
105
+ when Array
106
+ obj.map { |item| remove_binary_fields(item, fields_to_remove) }
107
+ else
108
+ obj
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module NetHttpInterceptor
5
+ include BaseInterceptor
6
+
7
+ # Response streaming interceptor nested under NetHttpInterceptor
8
+ module ResponseInterceptor
9
+ def read_body(dest = nil, &block)
10
+ return super unless block
11
+
12
+ super do |chunk|
13
+ Thread.current[:coolhand_stream_buffer] ||= +""
14
+ Thread.current[:coolhand_stream_buffer] << chunk
15
+ yield(chunk)
16
+ end
17
+ end
18
+ end
19
+
20
+ def self.patch!
21
+ return if @patched
22
+
23
+ Net::HTTP.prepend(self)
24
+ Net::HTTPResponse.prepend(ResponseInterceptor)
25
+
26
+ @patched = true
27
+ Coolhand.log "🔗 Net::HTTP interceptor patched"
28
+ end
29
+
30
+ def self.unpatch!
31
+ # NOTE: With prepend, there's no clean way to unpatch
32
+ # We'll mark it as unpatched so it can be re-patched
33
+ @patched = false
34
+ Coolhand.log "🔌 Faraday monitoring disabled ..."
35
+ end
36
+
37
+ def self.patched?
38
+ @patched
39
+ end
40
+
41
+ def request(req, body = nil, &block)
42
+ return super unless NetHttpInterceptor.patched?
43
+
44
+ active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
45
+ return super if active.key?(self)
46
+
47
+ url = build_url_for_request(self, req)
48
+ return super unless intercept?(url)
49
+ return super unless should_capture?
50
+
51
+ # Capture body before setting the guard — if this raises we skip logging cleanly
52
+ # and the guard is never set, so there is no leak.
53
+ captured_body = capture_request_body(req, body)
54
+
55
+ active[self] = true
56
+ start_time = Time.now
57
+ request_id = SecureRandom.uuid
58
+ response = nil
59
+ status_code = nil
60
+ response_body = nil
61
+
62
+ Thread.current[:coolhand_stream_buffer] = nil
63
+
64
+ begin
65
+ response = super
66
+ body_content = Thread.current[:coolhand_stream_buffer] || response&.body
67
+ body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
68
+ status_code = response.respond_to?(:code) ? response.code.to_i : nil
69
+ response_body = parse_json(body_content)
70
+ rescue StandardError => e
71
+ status_code = extract_status_from_exception(e)
72
+ response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
73
+ raise
74
+ ensure
75
+ active.delete(self)
76
+ Thread.current[:coolhand_stream_buffer] = nil
77
+ end_time = Time.now
78
+ duration_ms = ((end_time - start_time) * 1000).round(2)
79
+
80
+ send_complete_request_log(
81
+ request_id: request_id,
82
+ method: req.method,
83
+ url: url,
84
+ request_headers: sanitize_headers(req),
85
+ request_body: captured_body,
86
+ response_headers: sanitize_headers(response),
87
+ response_body: response_body,
88
+ status_code: status_code,
89
+ start_time: start_time,
90
+ end_time: end_time,
91
+ duration_ms: duration_ms,
92
+ is_streaming: !!block
93
+ )
94
+ end
95
+
96
+ response
97
+ end
98
+
99
+ private
100
+
101
+ def should_capture?
102
+ return true if Coolhand.configuration.debug_mode
103
+
104
+ override = Thread.current[:coolhand_capture_override]
105
+ return override unless override.nil?
106
+
107
+ Coolhand.configuration.capture
108
+ end
109
+
110
+ def capture_request_body(req, body)
111
+ return parse_json(body) if body
112
+ return parse_json(req.body) if req.body
113
+
114
+ if req.respond_to?(:body_stream) && req.body_stream
115
+ content = req.body_stream.read
116
+ req.body_stream = StringIO.new(content)
117
+ return parse_json(content)
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ def extract_status_from_exception(e)
124
+ return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
125
+ return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
126
+
127
+ match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
128
+ match ? match[1].to_i : nil
129
+ end
130
+
131
+ def intercept?(url)
132
+ return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
133
+ return false if excluded_by_pattern?(url)
134
+
135
+ Coolhand.configuration.intercept_addresses.any? { |a| url.include?(a) }
136
+ end
137
+
138
+ def excluded_by_pattern?(url)
139
+ patterns = Coolhand.configuration.exclude_api_patterns
140
+ return false if patterns.nil? || patterns.empty?
141
+
142
+ matched = patterns.find { |pattern| url.include?(pattern) }
143
+ if matched && Coolhand.configuration.debug_mode
144
+ Coolhand.log "🚫 Skipping capture for #{url} (matched exclude_api_pattern: \"#{matched}\")"
145
+ end
146
+ !!matched
147
+ end
148
+
149
+ def build_url_for_request(http, req)
150
+ return req.path if %r{\Ahttps?://}.match?(req.path)
151
+
152
+ scheme = http.use_ssl? ? "https" : "http"
153
+ host = http.address
154
+ port = http.port
155
+ default = http.use_ssl? ? 443 : 80
156
+
157
+ url = "#{scheme}://#{host}"
158
+ url << ":#{port}" if port != default
159
+ url << req.path
160
+ url
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module OpenAi
5
+ class BatchResultProcessor
6
+ attr_reader :event_data
7
+
8
+ def initialize(event_data:)
9
+ @event_data = event_data
10
+ end
11
+
12
+ def call
13
+ Rails.logger.info("[Interceptor] BatchResultProcessor: #{event_data}")
14
+
15
+ case batch_info["status"]
16
+ when "completed"
17
+ process_completed_batch
18
+ when "failed", "expired", "cancelled"
19
+ handle_failed_batch
20
+ when "in_progress", "validating", "finalizing"
21
+ Rails.logger.info("[Interceptor] OpenAI batch #{event_data} still processing")
22
+ else
23
+ Rails.logger.warn("[Interceptor] Unknown batch status: #{batch_info['status']} for batch #{event_data}")
24
+ end
25
+ rescue StandardError => e
26
+ Rails.logger.error("[Interceptor] Failed to process OpenAI batch results for #{event_data}: #{e.message}")
27
+ end
28
+
29
+ private
30
+
31
+ def process_completed_batch
32
+ input_file_id = batch_info["input_file_id"]
33
+ return unless input_file_id
34
+
35
+ output_file_id = batch_info["output_file_id"]
36
+ return unless output_file_id
37
+
38
+ # Download and process results
39
+ batch_request_items = download_batch_results(input_file_id)
40
+
41
+ # Download and process results
42
+ batch_response_items = download_batch_results(output_file_id)
43
+
44
+ batch_response_items.each do |response_item|
45
+ request_item = batch_request_items.detect { |item| item["custom_id"] == response_item["custom_id"] }
46
+
47
+ next unless request_item
48
+
49
+ send_complete_request_log(request_id: response_item["response"]["request_id"],
50
+ method: request_item["method"],
51
+ url: request_item["url"],
52
+ request_body: request_item["body"],
53
+ response_body: response_item["response"]["body"],
54
+ status_code: response_item["response"]["status_code"],
55
+ start_time: batch_info["in_progress_at"].to_i,
56
+ end_time: batch_info["completed_at"].to_i)
57
+ rescue StandardError => e
58
+ Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
59
+ end
60
+
61
+ Rails.logger.info("[Interceptor] Successfully processed OpenAI batch #{batch_info['id']}")
62
+ end
63
+
64
+ def download_batch_results(file_id)
65
+ file_content = client.files.content(id: file_id)
66
+
67
+ # Handle both string and array responses from the OpenAI client
68
+ if file_content.is_a?(Array)
69
+ # Client already parsed the JSONL into an array
70
+ file_content
71
+ else
72
+ # Parse JSONL response manually
73
+ file_content.split("\n").filter_map do |line|
74
+ line.strip!
75
+ next if line.empty?
76
+
77
+ JSON.parse(line)
78
+ end
79
+ end
80
+ rescue JSON::ParserError => e
81
+ Rails.logger.error("[Interceptor] Failed to parse OpenAI batch results: #{e.message}")
82
+ []
83
+ rescue StandardError => e
84
+ Rails.logger.error("[Interceptor] Failed to download OpenAI batch results: #{e.message}")
85
+ []
86
+ end
87
+
88
+ def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
89
+ start_time:, end_time:)
90
+ timestamp = Time.at(start_time).iso8601
91
+ completed_at = Time.at(end_time).iso8601
92
+ duration_ms = ((end_time - start_time) * 1000).to_i
93
+
94
+ request_data = {
95
+ raw_request: {
96
+ id: request_id,
97
+ timestamp: timestamp,
98
+ method: method.to_s.downcase,
99
+ url: url,
100
+ headers: {},
101
+ request_body: request_body,
102
+ response_headers: {},
103
+ response_body: response_body,
104
+ status_code: status_code,
105
+ duration_ms: duration_ms,
106
+ completed_at: completed_at,
107
+ is_streaming: false
108
+ }
109
+ }
110
+
111
+ api_service = Coolhand::ApiService.new
112
+ api_service.send_llm_request_log(request_data)
113
+
114
+ Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
115
+ rescue StandardError => e
116
+ Coolhand.log "❌ Error sending complete request log: #{e.message}"
117
+ end
118
+
119
+ def batch_info
120
+ @batch_info ||= client.batches.retrieve(id: event_data["id"])
121
+ end
122
+
123
+ def client
124
+ @client ||= begin
125
+ require "openai"
126
+ OpenAI::Client.new
127
+ rescue LoadError => e
128
+ raise LoadError, "The 'openai' gem is required to process OpenAI batches. " \
129
+ "Install it with: gem 'openai'\n\nOriginal error: #{e.message}"
130
+ end
131
+ end
132
+
133
+ # TODO: implement API to handle failed batch results and display errors on dashboard page
134
+ def handle_failed_batch
135
+ Rails.logger.error("[Interceptor] OpenAI batch #{batch_info['id']} failed: #{batch_info['errors']}")
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module OpenAi
5
+ class WebhookValidator
6
+ attr_reader :request, :errors, :payload, :webhook_secret
7
+
8
+ def initialize(request, webhook_secret)
9
+ @request = request
10
+ @errors = []
11
+ @webhook_secret = webhook_secret
12
+ end
13
+
14
+ def valid?
15
+ @errors.clear
16
+ @payload = request.raw_post || request.body.read
17
+
18
+ return false unless payload_valid?
19
+ return validate_in_non_production_env unless webhook_secret
20
+
21
+ secret_bytes = extract_secret_bytes
22
+ webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
23
+
24
+ return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
25
+
26
+ verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
27
+ end
28
+
29
+ def error_message
30
+ @errors.join(", ")
31
+ end
32
+
33
+ private
34
+
35
+ def payload_valid?
36
+ return true if @payload
37
+
38
+ if should_enforce_strict_validation?
39
+ @errors << "Empty webhook payload - rejecting webhook in production/staging"
40
+ Rails.logger.error(@errors.last)
41
+ false
42
+ else
43
+ Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
44
+ true
45
+ end
46
+ end
47
+
48
+ def validate_in_non_production_env
49
+ if should_enforce_strict_validation?
50
+ @errors << "OpenAI webhook secret not configured - rejecting webhook in production/staging"
51
+ Rails.logger.error(@errors.last)
52
+ false
53
+ else
54
+ Rails.logger.warn(
55
+ "Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
56
+ )
57
+ true
58
+ end
59
+ end
60
+
61
+ def extract_secret_bytes
62
+ if @webhook_secret.start_with?("whsec_")
63
+ webhook_secret_key = @webhook_secret[6..]
64
+ Base64.strict_decode64(webhook_secret_key)
65
+ else
66
+ @webhook_secret
67
+ end
68
+ end
69
+
70
+ def extract_webhook_headers
71
+ webhook_signature = request.headers["webhook-signature"] || request.headers["openai-signature"]
72
+ webhook_timestamp = request.headers["webhook-timestamp"] || request.headers["openai-timestamp"]
73
+ webhook_id = request.headers["webhook-id"] || request.headers["openai-id"]
74
+ [webhook_signature, webhook_timestamp, webhook_id]
75
+ end
76
+
77
+ def validate_headers_in_non_production_env
78
+ if should_enforce_strict_validation?
79
+ @errors << "Missing OpenAI webhook signature or timestamp headers - " \
80
+ "rejecting webhook in production/staging"
81
+ Rails.logger.error(@errors.last)
82
+ false
83
+ else
84
+ Rails.logger.warn("Missing OpenAI webhook headers - skipping verification in #{Rails.env}")
85
+ true
86
+ end
87
+ end
88
+
89
+ def verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
90
+ signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
91
+ expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
92
+
93
+ signature_valid = webhook_signature.start_with?("v1,") &&
94
+ secure_compare(webhook_signature[3..], expected_signature)
95
+ if signature_valid
96
+ true
97
+ else
98
+ @errors << "OpenAI webhook signature verification failed"
99
+ Rails.logger.error(@errors.last)
100
+ false
101
+ end
102
+ end
103
+
104
+ def secure_compare(a, b)
105
+ return false unless a.bytesize == b.bytesize
106
+
107
+ result = 0
108
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
109
+ result.zero?
110
+ end
111
+
112
+ def calculate_expected_signature(secret_bytes, signed_payload)
113
+ Base64.strict_encode64(
114
+ OpenSSL::HMAC.digest(
115
+ OpenSSL::Digest.new("sha256"),
116
+ secret_bytes,
117
+ signed_payload
118
+ )
119
+ )
120
+ end
121
+
122
+ def should_enforce_strict_validation?
123
+ ["production", "staging"].include?(Rails.env)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- module Ruby
5
- VERSION = "0.2.0"
6
- end
4
+ VERSION = "0.3.0"
7
5
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module Vertex
5
+ class BatchResultProcessor
6
+ attr_reader :batch_info
7
+
8
+ def initialize(batch_info:)
9
+ @batch_info = batch_info
10
+ end
11
+
12
+ def call(batch_results = [])
13
+ Rails.logger.info("[Interceptor] BatchResultProcessor: #{batch_info}")
14
+
15
+ case batch_info["state"]
16
+ when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
17
+ Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
18
+ when "JOB_STATE_SUCCEEDED"
19
+ batch_results.each { |batch_item| process_completed_batch(batch_item) }
20
+ when "JOB_STATE_FAILED"
21
+ handle_failed_batch
22
+ else
23
+ Rails.logger.warn("[Interceptor] Unknown batch status: #{batch_info['state']} for batch #{batch_info}")
24
+ end
25
+ rescue StandardError => e
26
+ Rails.logger.error("[Interceptor] Failed to process Vertex batch results for #{batch_info}: #{e.message}")
27
+ end
28
+
29
+ private
30
+
31
+ def process_completed_batch(batch_item)
32
+ send_complete_request_log(request_id: SecureRandom.hex(16),
33
+ method: "POST",
34
+ url: batch_info["name"],
35
+ request_body: batch_item["request"],
36
+ response_body: batch_item["response"],
37
+ status_code: 200,
38
+ start_time: batch_info["startTime"],
39
+ end_time: batch_info["endTime"])
40
+
41
+ Rails.logger.info("[Interceptor] Successfully processed Vertex batch #{batch_info['displayName']}")
42
+ rescue StandardError => e
43
+ Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
44
+ end
45
+
46
+ def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
47
+ start_time:, end_time:)
48
+ start_time = Time.iso8601(start_time)
49
+ end_time = Time.iso8601(end_time)
50
+ duration_ms = ((end_time - start_time) * 1000).to_i
51
+
52
+ request_data = {
53
+ raw_request: {
54
+ id: request_id,
55
+ timestamp: start_time,
56
+ method: method.to_s.downcase,
57
+ url: url,
58
+ headers: {},
59
+ request_body: request_body,
60
+ response_headers: {},
61
+ response_body: response_body,
62
+ status_code: status_code,
63
+ duration_ms: duration_ms,
64
+ completed_at: end_time,
65
+ is_streaming: false
66
+ }
67
+ }
68
+
69
+ api_service = Coolhand::ApiService.new
70
+ api_service.send_llm_request_log(request_data)
71
+
72
+ Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
73
+ rescue StandardError => e
74
+ Coolhand.log "❌ Error sending complete request log: #{e.message}"
75
+ end
76
+
77
+ # TODO: implement API to handle failed batch results and display errors on dashboard page
78
+ def handle_failed_batch
79
+ Rails.logger.error("[Interceptor] Vertex batch for #{batch_info['displayName']} " \
80
+ "failed: #{batch_info['error']['message']}")
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module WebhookInterceptor
5
+ def intercept_batch_request
6
+ Rails.logger.info("[Interceptor] #{controller_name}##{action_name}")
7
+
8
+ @validator = Coolhand::OpenAi::WebhookValidator.new(request, webhook_secret)
9
+
10
+ unless @validator.valid?
11
+ Rails.logger.info("[Interceptor] Webhook validated failed: #{@validator.error_message}")
12
+ head :unauthorized
13
+ return false
14
+ end
15
+
16
+ payload = JSON.parse(@validator.payload)
17
+
18
+ process_event(payload)
19
+ rescue StandardError => e
20
+ Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
21
+ end
22
+
23
+ def webhook_secret
24
+ raise NotImplementedError, "#{self.class} must implement #webhook_secret"
25
+ end
26
+
27
+ def process_event(payload)
28
+ event_type = payload["type"]
29
+ event_data = payload["data"]
30
+
31
+ case event_type
32
+ when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
33
+ Coolhand::OpenAi::BatchResultProcessor.new(event_data: event_data).call
34
+ else
35
+ Rails.logger.info("[Interceptor] Unhandled OpenAI webhook event type: #{event_type}")
36
+ end
37
+ end
38
+ end
39
+ end