instana 2.7.2 → 2.8.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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/db'
7
+ require 'opentelemetry/semconv/server'
8
+
9
+ module Instana
10
+ module Exporter
11
+ module Otlp
12
+ # Converter for database spans to OTLP format
13
+ class DatabaseConverter < BaseConverter
14
+ def convert_attributes
15
+ attributes = {}
16
+ data = span[:data] || {}
17
+
18
+ convert_activerecord_attributes(attributes, data[:activerecord])
19
+ convert_sequel_attributes(attributes, data[:sequel])
20
+ convert_redis_attributes(attributes, data[:redis])
21
+ convert_memcache_attributes(attributes, data[:memcache])
22
+ convert_mongo_attributes(attributes, data[:mongo])
23
+
24
+ attributes
25
+ end
26
+
27
+ # Build OTel-compliant span name for database spans
28
+ #
29
+ # Formulas per SPAN_NAME_PATTERNS.txt Section 2:
30
+ # activerecord / sequel → "{adapter} {db}" e.g. "mysql2 myapp"
31
+ # redis → "redis {command}" e.g. "redis GET"
32
+ # memcache → "memcached {command}" e.g. "memcached get"
33
+ # mongo → "{namespace}.{command}" e.g. "users.find"
34
+ #
35
+ # @return [String] The span name
36
+ def span_name
37
+ data = span[:data] || {}
38
+ activerecord_span_name(data[:activerecord]) ||
39
+ sequel_span_name(data[:sequel]) ||
40
+ redis_span_name(data[:redis]) ||
41
+ memcache_span_name(data[:memcache]) ||
42
+ mongo_span_name(data[:mongo]) ||
43
+ super
44
+ end
45
+
46
+ private
47
+
48
+ def activerecord_span_name(ar_data)
49
+ return unless ar_data
50
+
51
+ parts = [ar_data[:adapter], ar_data[:db]].compact.reject(&:empty?)
52
+ parts.empty? ? 'activerecord' : parts.join(' ')
53
+ end
54
+
55
+ def sequel_span_name(seq)
56
+ return unless seq
57
+
58
+ parts = [seq[:adapter], seq[:db]].compact.reject(&:empty?)
59
+ parts.empty? ? 'sequel' : parts.join(' ')
60
+ end
61
+
62
+ def redis_span_name(redis)
63
+ return unless redis
64
+
65
+ cmd = redis[:command].to_s.strip
66
+ cmd.empty? ? 'redis' : "redis #{cmd}"
67
+ end
68
+
69
+ def memcache_span_name(mc_data)
70
+ return unless mc_data
71
+
72
+ cmd = mc_data[:command].to_s.strip
73
+ cmd.empty? ? 'memcached' : "memcached #{cmd}"
74
+ end
75
+
76
+ def mongo_span_name(mongo)
77
+ return unless mongo
78
+
79
+ ns = mongo[:namespace].to_s.strip
80
+ cmd = mongo[:command].to_s.strip
81
+ parts = [ns, cmd].reject(&:empty?)
82
+ parts.empty? ? 'mongodb' : parts.join('.')
83
+ end
84
+
85
+ def convert_activerecord_attributes(attributes, ar_data)
86
+ return unless ar_data
87
+
88
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, ar_data[:adapter])
89
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, ar_data[:db])
90
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, ar_data[:sql])
91
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::DB::DB_USER, ar_data[:username])
92
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, ar_data[:host])
93
+ end
94
+
95
+ def convert_sequel_attributes(attributes, seq_data)
96
+ return unless seq_data
97
+
98
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, seq_data[:adapter])
99
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, seq_data[:db])
100
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, seq_data[:sql])
101
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::DB::DB_USER, seq_data[:username])
102
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, seq_data[:host])
103
+ end
104
+
105
+ def convert_redis_attributes(attributes, redis_data)
106
+ return unless redis_data
107
+
108
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'redis')
109
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, redis_data[:command])
110
+ add_attribute(attributes, 'db.redis.database_index', redis_data[:db])
111
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(redis_data[:connection]))
112
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(redis_data[:connection]))
113
+ end
114
+
115
+ def convert_memcache_attributes(attributes, mc_data)
116
+ return unless mc_data
117
+
118
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'memcached')
119
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, mc_data[:command])
120
+ add_attribute(attributes, 'db.memcached.key', mc_data[:key])
121
+ add_attribute(attributes, 'db.memcached.keys', mc_data[:keys])
122
+ add_attribute(attributes, 'db.memcached.namespace', mc_data[:namespace])
123
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(mc_data[:server]))
124
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(mc_data[:server]))
125
+ end
126
+
127
+ def convert_mongo_attributes(attributes, mongo_data)
128
+ return unless mongo_data
129
+
130
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_SYSTEM_NAME, 'mongodb')
131
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_NAMESPACE, mongo_data[:namespace])
132
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_OPERATION_NAME, mongo_data[:command])
133
+ add_attribute(attributes, OpenTelemetry::SemConv::DB::DB_QUERY_TEXT, mongo_data[:json])
134
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, mongo_data.dig(:peer, :hostname))
135
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, mongo_data.dig(:peer, :port))
136
+ end
137
+
138
+ def extract_host(connection)
139
+ return nil unless connection
140
+
141
+ connection.to_s.split(':').first
142
+ end
143
+
144
+ def extract_port(connection)
145
+ return nil unless connection
146
+
147
+ port = connection.to_s.split(':').last
148
+ port.to_i if port =~ /^\d+$/
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/incubating/graphql'
7
+
8
+ module Instana
9
+ module Exporter
10
+ module Otlp
11
+ # Converter for GraphQL spans to OTLP format
12
+ class GraphqlConverter < BaseConverter
13
+ # Build OTel-compliant span name for GraphQL spans
14
+ #
15
+ # Formula per SPAN_NAME_PATTERNS.txt Section 7 (observability):
16
+ # "{operationType} {operationName}" e.g. "query MyQuery"
17
+ # Falls back to just "{operationType}" when no name, or "graphql" when both absent.
18
+ #
19
+ # @return [String] The span name
20
+ def span_name
21
+ gql = span[:data]&.[](:graphql) || {}
22
+ type = gql[:operationType].to_s.strip
23
+ name = gql[:operationName].to_s.strip
24
+
25
+ if type.empty?
26
+ 'graphql'
27
+ elsif name.empty?
28
+ type
29
+ else
30
+ "#{type} #{name}"
31
+ end
32
+ end
33
+
34
+ def convert_attributes
35
+ attributes = {}
36
+
37
+ graphql_data = span[:data]&.[](:graphql)
38
+ return attributes unless graphql_data
39
+
40
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_OPERATION_NAME, graphql_data[:operationName])
41
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_OPERATION_TYPE, graphql_data[:operationType])
42
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::GRAPHQL::GRAPHQL_DOCUMENT, format_fields(graphql_data[:fields]))
43
+
44
+ # Add arguments as custom attribute
45
+ add_attribute(attributes, 'graphql.arguments', format_arguments(graphql_data[:arguments])) if graphql_data[:arguments]
46
+
47
+ attributes
48
+ end
49
+
50
+ private
51
+
52
+ def format_fields(fields)
53
+ return nil unless fields
54
+
55
+ fields.map { |obj, flds| "#{obj} { #{flds.join(', ')} }" }.join(', ')
56
+ end
57
+
58
+ def format_arguments(arguments)
59
+ return nil unless arguments
60
+
61
+ arguments.map { |obj, args| "#{obj}(#{args.join(', ')})" }.join(', ')
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/http'
7
+ require 'opentelemetry/semconv/url'
8
+ require 'opentelemetry/semconv/server'
9
+ require 'opentelemetry/semconv/user_agent'
10
+
11
+ module Instana
12
+ module Exporter
13
+ module Otlp
14
+ # Converter for HTTP spans to OTLP format
15
+ # Handles conversion of HTTP-related spans with specific attributes
16
+ class HttpConverter < BaseConverter
17
+ # Extract HTTP-specific attributes as plain key/value pairs
18
+ # @return [Hash] HTTP attributes
19
+ def convert_attributes
20
+ attributes = {}
21
+ http_data = span[:data]&.[](:http) || {}
22
+
23
+ add_attribute(attributes, OpenTelemetry::SemConv::HTTP::HTTP_REQUEST_METHOD, http_data[:method])
24
+ add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_FULL, http_data[:url])
25
+ add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_PATH, http_data[:path])
26
+ add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_QUERY, http_data[:params])
27
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, extract_host(http_data[:host]))
28
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_PORT, extract_port(http_data[:host], http_data[:url]))
29
+ add_attribute(attributes, OpenTelemetry::SemConv::URL::URL_SCHEME, extract_scheme(http_data[:url]))
30
+ add_attribute(attributes, OpenTelemetry::SemConv::HTTP::HTTP_RESPONSE_STATUS_CODE, http_data[:status])
31
+ add_attribute(attributes, OpenTelemetry::SemConv::USER_AGENT::USER_AGENT_ORIGINAL, http_data.dig(:header, 'user-agent'))
32
+
33
+ add_protocol_attributes(attributes, http_data[:protocol])
34
+
35
+ attributes
36
+ end
37
+
38
+ # Build OTel-compliant span status, treating EXIT spans with 4xx as ERROR
39
+ #
40
+ # @param error_count [Integer] Span error count
41
+ # @param error_msg [String, nil] Pre-extracted error message
42
+ # @return [Status]
43
+ def build_status(error_count, error_msg)
44
+ return super unless error_count.zero?
45
+
46
+ http_data = span[:data]&.[](:http) || {}
47
+ status_code = http_data[:status].to_i
48
+
49
+ if convert_span_kind == :client && status_code >= 400 && status_code < 500
50
+ Status.new(OpenTelemetry::Trace::Status::ERROR, '')
51
+ else
52
+ super
53
+ end
54
+ end
55
+
56
+ # Build OTel-compliant span name for HTTP spans
57
+ #
58
+ # Convention (stable): "{METHOD}" or "{METHOD} {url.template/path}"
59
+ # Falls back to "HTTP" when no method is present.
60
+ #
61
+ # @return [String] The span name
62
+ def span_name
63
+ http_data = span[:data]&.[](:http) || {}
64
+ method = http_data[:method].to_s.upcase
65
+ method = 'HTTP' if method.empty?
66
+
67
+ path = http_data[:path].to_s.strip
68
+ path.empty? ? method : "#{method} #{path}"
69
+ end
70
+
71
+ private
72
+
73
+ # Extract scheme from URL
74
+ # @param url [String] The URL
75
+ # @return [String, nil] The scheme (http or https)
76
+ def extract_scheme(url)
77
+ return nil unless url
78
+
79
+ uri = URI.parse(url)
80
+ uri.scheme
81
+ rescue URI::InvalidURIError
82
+ nil
83
+ end
84
+
85
+ # Extract the host part from a "host:port" string or bare hostname
86
+ # @param host_str [String, nil] e.g. "api.example.com:8080" or "api.example.com"
87
+ # @return [String, nil]
88
+ def extract_host(host_str)
89
+ return nil unless host_str
90
+
91
+ part = host_str.to_s.split(':').first
92
+ part && !part.empty? ? part : host_str
93
+ end
94
+
95
+ # Extract the port from a "host:port" string, falling back to the URL port
96
+ # @param host_str [String, nil] e.g. "api.example.com:8080"
97
+ # @param url [String, nil] full URL as fallback
98
+ # @return [Integer, nil]
99
+ def extract_port(host_str, url)
100
+ if host_str&.include?(':')
101
+ port = host_str.split(':').last
102
+ return port.to_i unless port.nil? || port.empty?
103
+ end
104
+
105
+ return nil unless url
106
+
107
+ uri = URI.parse(url)
108
+ uri.port
109
+ rescue URI::InvalidURIError
110
+ nil
111
+ end
112
+
113
+ # Emit network.protocol.name and network.protocol.version from e.g. "HTTP/1.1"
114
+ # @param attributes [Hash]
115
+ # @param protocol [String, nil] e.g. "HTTP/1.1" or "h2"
116
+ def add_protocol_attributes(attributes, protocol)
117
+ return unless protocol
118
+
119
+ parts = protocol.to_s.split('/', 2)
120
+ name = parts[0].downcase
121
+ version = parts[1]
122
+
123
+ add_attribute(attributes, 'network.protocol.name', name)
124
+ add_attribute(attributes, 'network.protocol.version', version)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/incubating/messaging'
7
+ require 'opentelemetry/semconv/server'
8
+
9
+ module Instana
10
+ module Exporter
11
+ module Otlp
12
+ # Converter for messaging spans to OTLP format
13
+ class MessagingConverter < BaseConverter
14
+ # Build OTel-compliant span name for messaging (RabbitMQ) spans
15
+ #
16
+ # Formula per SPAN_NAME_PATTERNS.txt Section 3 (bunny/AMQP):
17
+ # publish → "{exchange} publish" (or "{queue} publish" when no exchange)
18
+ # receive → "{queue} receive"
19
+ #
20
+ # @return [String] The span name
21
+ def span_name
22
+ rabbitmq_data = span[:data]&.[](:rabbitmq) || {}
23
+ sort = rabbitmq_data[:sort].to_s
24
+
25
+ if sort == 'publish'
26
+ dest = rabbitmq_data[:exchange].to_s.strip
27
+ dest = rabbitmq_data[:queue].to_s.strip if dest.empty?
28
+ dest.empty? ? 'publish' : "#{dest} publish"
29
+ else
30
+ queue = rabbitmq_data[:queue].to_s.strip
31
+ queue.empty? ? 'receive' : "#{queue} receive"
32
+ end
33
+ end
34
+
35
+ def convert_attributes
36
+ attributes = {}
37
+
38
+ rabbitmq_data = span[:data]&.[](:rabbitmq)
39
+ if rabbitmq_data
40
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_SYSTEM, 'rabbitmq')
41
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_DESTINATION_NAME,
42
+ rabbitmq_destination_name(rabbitmq_data))
43
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_RABBITMQ_DESTINATION_ROUTING_KEY, rabbitmq_data[:key])
44
+ add_attribute(attributes, 'messaging.rabbitmq.queue', rabbitmq_data[:queue])
45
+ add_attribute(attributes, OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS, rabbitmq_data[:address])
46
+
47
+ operation = rabbitmq_data[:sort] == 'publish' ? 'send' : 'receive'
48
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::MESSAGING::MESSAGING_OPERATION_TYPE, operation)
49
+ end
50
+
51
+ attributes
52
+ end
53
+
54
+ private
55
+
56
+ # Build the composite destination name per spec:
57
+ # Producer (publish): "{exchange}:{key}" — omit absent parts
58
+ # Consumer (receive): "{exchange}:{key}:{queue}" — omit absent; deduplicate key==queue
59
+ #
60
+ # @param data [Hash] rabbitmq span data
61
+ # @return [String, nil]
62
+ def rabbitmq_destination_name(data)
63
+ exchange = data[:exchange].to_s.strip
64
+ key = data[:key].to_s.strip
65
+ queue = data[:queue].to_s.strip
66
+ sort = data[:sort].to_s
67
+
68
+ if sort == 'publish'
69
+ parts = [exchange, key].reject(&:empty?)
70
+ else
71
+ # Consumer: exchange:key:queue, dedup key==queue
72
+ parts = [exchange, key]
73
+ parts << queue unless queue.empty? || queue == key
74
+ parts = parts.reject(&:empty?)
75
+ end
76
+ parts.empty? ? nil : parts.join(':')
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2026
4
+
5
+ require_relative 'base_converter'
6
+ require 'opentelemetry/semconv/incubating/code'
7
+
8
+ module Instana
9
+ module Exporter
10
+ module Otlp
11
+ # Converter for Rails-related spans (ActionController, ActionView, ActionMailer) to OTLP format
12
+ class RailsConverter < BaseConverter
13
+ ACTIONMAILER_SPAN = 'mail.actionmailer'
14
+
15
+ # Build OTel-compliant span name for Rails spans
16
+ #
17
+ # Formulas per SPAN_NAME_PATTERNS.txt Sections 5 & 7:
18
+ # actioncontroller → "{Controller}#{action}" e.g. "UsersController#index"
19
+ # actionview → "{view_name}" e.g. "users/index"
20
+ # render → "{type} {name}" e.g. "template users/index"
21
+ # mail.actionmailer → "{Class}#{method}" e.g. "UserMailer#welcome_email"
22
+ #
23
+ # @return [String] The span name
24
+ def span_name
25
+ case span[:n].to_s
26
+ when 'actioncontroller'
27
+ d = span_data_for(:actioncontroller)
28
+ ctrl = d[:controller].to_s.strip
29
+ action = d[:action].to_s.strip
30
+ parts = [ctrl, action].reject(&:empty?)
31
+ parts.empty? ? 'actioncontroller' : parts.join('#')
32
+ when 'actionview'
33
+ d = span_data_for(:actionview)
34
+ d[:name].to_s.strip.then { |n| n.empty? ? 'actionview' : n }
35
+ when 'render'
36
+ d = span_data_for(:render)
37
+ type = d[:type].to_s.strip
38
+ name = d[:name].to_s.strip
39
+ parts = [type, name].reject(&:empty?)
40
+ parts.empty? ? 'render' : parts.join(' ')
41
+ when ACTIONMAILER_SPAN
42
+ d = span_data_for(:actionmailer)
43
+ klass = d[:class].to_s.strip
44
+ method = d[:method].to_s.strip
45
+ parts = [klass, method].reject(&:empty?)
46
+ parts.empty? ? ACTIONMAILER_SPAN : parts.join('#')
47
+ else
48
+ super
49
+ end
50
+ end
51
+
52
+ def convert_attributes
53
+ attributes = {}
54
+ span_type = span[:n].to_s
55
+
56
+ if span_type == 'actioncontroller' # rubocop:disable Style/CaseLikeIf
57
+ convert_action_controller_attributes(attributes)
58
+ elsif span_type == 'actionview'
59
+ convert_action_view_attributes(attributes)
60
+ elsif span_type == 'render'
61
+ convert_render_attributes(attributes)
62
+ elsif span_type == ACTIONMAILER_SPAN
63
+ convert_action_mailer_attributes(attributes)
64
+ end
65
+
66
+ attributes
67
+ end
68
+
69
+ private
70
+
71
+ # Return the data hash for a given span data key, falling back to
72
+ # top-level span key, then an empty hash.
73
+ def span_data_for(key)
74
+ span[:data]&.[](key) || span[key] || {}
75
+ end
76
+
77
+ # Convert ActionController span attributes
78
+ def convert_action_controller_attributes(attributes)
79
+ controller_data = span[:data]&.[](:actioncontroller) || span[:actioncontroller]
80
+ return unless controller_data
81
+
82
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_NAMESPACE, controller_data[:controller])
83
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_FUNCTION, controller_data[:action])
84
+ end
85
+
86
+ # Convert ActionView span attributes
87
+ def convert_action_view_attributes(attributes)
88
+ view_data = span[:data]&.[](:actionview) || span[:actionview]
89
+ return unless view_data
90
+
91
+ add_attribute(attributes, 'rails.view.name', view_data[:name])
92
+ end
93
+
94
+ # Convert render span attributes
95
+ def convert_render_attributes(attributes)
96
+ render_data = span[:data]&.[](:render) || span[:render]
97
+ return unless render_data
98
+
99
+ add_attribute(attributes, 'rails.render.type', render_data[:type])
100
+ add_attribute(attributes, 'rails.render.name', render_data[:name])
101
+ end
102
+
103
+ # Convert ActionMailer span attributes
104
+ def convert_action_mailer_attributes(attributes)
105
+ mailer_data = span[:data]&.[](:actionmailer) || span[:actionmailer]
106
+ return unless mailer_data
107
+
108
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_NAMESPACE, mailer_data[:class])
109
+ add_attribute(attributes, OpenTelemetry::SemConv::Incubating::CODE::CODE_FUNCTION, mailer_data[:method])
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end