lapsoss 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +855 -0
- data/lib/lapsoss/adapters/appsignal_adapter.rb +136 -0
- data/lib/lapsoss/adapters/base.rb +88 -0
- data/lib/lapsoss/adapters/insight_hub_adapter.rb +190 -0
- data/lib/lapsoss/adapters/logger_adapter.rb +67 -0
- data/lib/lapsoss/adapters/rollbar_adapter.rb +157 -0
- data/lib/lapsoss/adapters/sentry_adapter.rb +197 -0
- data/lib/lapsoss/backtrace_frame.rb +258 -0
- data/lib/lapsoss/backtrace_processor.rb +346 -0
- data/lib/lapsoss/client.rb +115 -0
- data/lib/lapsoss/configuration.rb +310 -0
- data/lib/lapsoss/current.rb +9 -0
- data/lib/lapsoss/event.rb +107 -0
- data/lib/lapsoss/exclusions.rb +429 -0
- data/lib/lapsoss/fingerprinter.rb +217 -0
- data/lib/lapsoss/http_client.rb +79 -0
- data/lib/lapsoss/middleware.rb +353 -0
- data/lib/lapsoss/pipeline.rb +131 -0
- data/lib/lapsoss/railtie.rb +72 -0
- data/lib/lapsoss/registry.rb +114 -0
- data/lib/lapsoss/release_tracker.rb +553 -0
- data/lib/lapsoss/router.rb +36 -0
- data/lib/lapsoss/sampling.rb +332 -0
- data/lib/lapsoss/scope.rb +110 -0
- data/lib/lapsoss/scrubber.rb +170 -0
- data/lib/lapsoss/user_context.rb +355 -0
- data/lib/lapsoss/validators.rb +142 -0
- data/lib/lapsoss/version.rb +5 -0
- data/lib/lapsoss.rb +76 -0
- metadata +217 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "../http_client"
|
|
5
|
+
require_relative "../backtrace_processor"
|
|
6
|
+
|
|
7
|
+
module Lapsoss
|
|
8
|
+
module Adapters
|
|
9
|
+
class SentryAdapter < Base
|
|
10
|
+
PROTOCOL_VERSION = 7
|
|
11
|
+
CONTENT_TYPE = "application/x-sentry-envelope"
|
|
12
|
+
GZIP_THRESHOLD = 1024 * 30 # 30KB
|
|
13
|
+
USER_AGENT = "lapsoss/#{Lapsoss::VERSION}"
|
|
14
|
+
|
|
15
|
+
def initialize(name, settings = {})
|
|
16
|
+
super(name, settings)
|
|
17
|
+
validate_settings!
|
|
18
|
+
return unless settings[:dsn]
|
|
19
|
+
|
|
20
|
+
@dsn = parse_dsn(settings[:dsn])
|
|
21
|
+
@protocol_version = settings[:protocol_version] || PROTOCOL_VERSION
|
|
22
|
+
@client = create_http_client(sentry_api_uri)
|
|
23
|
+
@backtrace_processor = BacktraceProcessor.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture(event)
|
|
27
|
+
return unless @client
|
|
28
|
+
|
|
29
|
+
envelope = build_envelope(event)
|
|
30
|
+
body, compressed = serialize_envelope(envelope)
|
|
31
|
+
|
|
32
|
+
headers = build_headers(compressed)
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
@client.post(@dsn[:path], body: body, headers: headers)
|
|
36
|
+
rescue DeliveryError => e
|
|
37
|
+
# Log the error and potentially notify error handler
|
|
38
|
+
Lapsoss.configuration.logger&.error("[Lapsoss::SentryAdapter] Failed to deliver event: #{e.message}")
|
|
39
|
+
Lapsoss.configuration.error_handler&.call(e)
|
|
40
|
+
|
|
41
|
+
# Re-raise to let the caller know delivery failed
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def shutdown
|
|
47
|
+
@client&.shutdown
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def capabilities
|
|
52
|
+
super.merge(
|
|
53
|
+
code_context: true,
|
|
54
|
+
breadcrumbs: true
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_envelope(event)
|
|
61
|
+
# This structure is specific to the Sentry Envelope format
|
|
62
|
+
header = {
|
|
63
|
+
event_id: event.context[:event_id] || SecureRandom.uuid,
|
|
64
|
+
sent_at: Time.now.iso8601,
|
|
65
|
+
sdk: { name: "lapsoss", version: Lapsoss::VERSION }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
item_type = event.type == :transaction ? "transaction" : "event"
|
|
69
|
+
item_header = { type: item_type, content_type: "application/json" }
|
|
70
|
+
item_payload = build_event_payload(event)
|
|
71
|
+
|
|
72
|
+
[header, item_header, item_payload]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def serialize_envelope(envelope)
|
|
76
|
+
header, item_header, item_payload = envelope
|
|
77
|
+
|
|
78
|
+
body = [
|
|
79
|
+
JSON.generate(header),
|
|
80
|
+
JSON.generate(item_header),
|
|
81
|
+
JSON.generate(item_payload)
|
|
82
|
+
].join("\n")
|
|
83
|
+
|
|
84
|
+
if body.bytesize >= GZIP_THRESHOLD
|
|
85
|
+
[Zlib.gzip(body), true]
|
|
86
|
+
else
|
|
87
|
+
[body, false]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_event_payload(event)
|
|
92
|
+
{
|
|
93
|
+
platform: "ruby",
|
|
94
|
+
level: map_level(event.level),
|
|
95
|
+
timestamp: event.timestamp.to_f,
|
|
96
|
+
environment: @settings[:environment],
|
|
97
|
+
release: @settings[:release],
|
|
98
|
+
tags: event.context[:tags],
|
|
99
|
+
user: event.context[:user],
|
|
100
|
+
extra: event.context[:extra],
|
|
101
|
+
breadcrumbs: { values: event.context[:breadcrumbs] || [] }
|
|
102
|
+
}.merge(event_specific_payload(event))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def event_specific_payload(event)
|
|
106
|
+
case event.type
|
|
107
|
+
when :exception
|
|
108
|
+
{
|
|
109
|
+
exception: {
|
|
110
|
+
values: [{
|
|
111
|
+
type: event.exception.class.name,
|
|
112
|
+
value: event.exception.message,
|
|
113
|
+
stacktrace: { frames: parse_backtrace(event.exception.backtrace) }
|
|
114
|
+
}]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
when :message
|
|
118
|
+
{ message: event.message }
|
|
119
|
+
else
|
|
120
|
+
{}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_headers(compressed)
|
|
125
|
+
{
|
|
126
|
+
"Content-Type" => CONTENT_TYPE,
|
|
127
|
+
"X-Sentry-Auth" => auth_header,
|
|
128
|
+
"Content-Encoding" => ("gzip" if compressed)
|
|
129
|
+
}.compact
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def auth_header
|
|
133
|
+
timestamp = Time.now.to_i
|
|
134
|
+
"Sentry sentry_version=#{@protocol_version}, sentry_client=#{USER_AGENT}, sentry_timestamp=#{timestamp}, sentry_key=#{@dsn[:public_key]}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_dsn(dsn_string)
|
|
138
|
+
uri = URI.parse(dsn_string)
|
|
139
|
+
|
|
140
|
+
# Trust the DSN path as provided - don't try to reconstruct it
|
|
141
|
+
# The DSN should contain the exact endpoint path to use
|
|
142
|
+
# Examples:
|
|
143
|
+
# - Standard Sentry: https://public_key@sentry.io/1 -> /api/1/envelope/
|
|
144
|
+
# - Custom service: https://public_key@custom.com/api/v1/errors -> /api/v1/errors
|
|
145
|
+
|
|
146
|
+
# Extract project ID for auth header (usually the last path segment)
|
|
147
|
+
path_parts = uri.path.split("/").reject(&:empty?)
|
|
148
|
+
project_id = path_parts.last || "unknown"
|
|
149
|
+
|
|
150
|
+
# Use the DSN path directly - this is what the service expects
|
|
151
|
+
api_path = uri.path
|
|
152
|
+
|
|
153
|
+
# For standard Sentry DSNs (just /project_id), build the envelope path
|
|
154
|
+
if path_parts.length == 1 && project_id.match?(/^\d+$/)
|
|
155
|
+
api_path = "/api/#{project_id}/envelope/"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
public_key: uri.user,
|
|
160
|
+
project_id: project_id,
|
|
161
|
+
path: api_path
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def sentry_api_uri
|
|
166
|
+
uri = URI.parse(@settings[:dsn])
|
|
167
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_backtrace(backtrace)
|
|
171
|
+
frames = @backtrace_processor.process(backtrace)
|
|
172
|
+
formatted_frames = @backtrace_processor.format_frames(frames, :sentry)
|
|
173
|
+
# Sentry expects frames in reverse order (most recent first)
|
|
174
|
+
formatted_frames.reverse
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def map_level(level)
|
|
178
|
+
case level
|
|
179
|
+
when :debug then "debug"
|
|
180
|
+
when :info then "info"
|
|
181
|
+
when :warn, :warning then "warning"
|
|
182
|
+
when :error then "error"
|
|
183
|
+
when :fatal then "fatal"
|
|
184
|
+
else "info"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def validate_settings!
|
|
189
|
+
if @settings[:dsn]
|
|
190
|
+
validate_dsn!(@settings[:dsn], "Sentry DSN")
|
|
191
|
+
else
|
|
192
|
+
raise ValidationError, "Sentry DSN is required"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lapsoss
|
|
4
|
+
class BacktraceFrame
|
|
5
|
+
# Backtrace line patterns for different Ruby implementations
|
|
6
|
+
BACKTRACE_PATTERNS = [
|
|
7
|
+
# Standard Ruby format: filename.rb:123:in `method_name'
|
|
8
|
+
/^(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
|
|
9
|
+
|
|
10
|
+
# Ruby format without method: filename.rb:123
|
|
11
|
+
/^(?<filename>[^:]+):(?<line>\d+)$/,
|
|
12
|
+
|
|
13
|
+
# JRuby format: filename.rb:123:in method_name
|
|
14
|
+
/^(?<filename>[^:]+):(?<line>\d+):in (?<method>.*)$/,
|
|
15
|
+
|
|
16
|
+
# Eval'd code: (eval):123:in `method_name'
|
|
17
|
+
/^\(eval\):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
|
|
18
|
+
|
|
19
|
+
# Block format: filename.rb:123:in `block in method_name'
|
|
20
|
+
/^(?<filename>[^:]+):(?<line>\d+):in [`']block (?<block_level>\(\d+\s+levels\)\s+)?in (?<method>.*?)[`']$/,
|
|
21
|
+
|
|
22
|
+
# Native extension format: [native_gem] filename.c:123:in `method_name'
|
|
23
|
+
/^\[(?<native_gem>[^\]]+)\]\s*(?<filename>[^:]+):(?<line>\d+):in [`'](?<method>.*?)[`']$/,
|
|
24
|
+
|
|
25
|
+
# Java backtrace format: org.jruby.Ruby.runScript(Ruby.java:123)
|
|
26
|
+
/^(?<method>[^(]+)\((?<filename>[^:)]+):(?<line>\d+)\)$/,
|
|
27
|
+
|
|
28
|
+
# Java backtrace format without line number: org.jruby.Ruby.runScript(Ruby.java)
|
|
29
|
+
/^(?<method>[^(]+)\((?<filename>[^:)]+)\)$/,
|
|
30
|
+
|
|
31
|
+
# Malformed Ruby format with invalid line number: filename.rb:abc:in `method'
|
|
32
|
+
/^(?<filename>[^:]+):(?<line>[^:]*):in [`'](?<method>.*?)[`']$/,
|
|
33
|
+
|
|
34
|
+
# Malformed Ruby format with missing line number: filename.rb::in `method'
|
|
35
|
+
/^(?<filename>[^:]+)::in [`'](?<method>.*?)[`']$/,
|
|
36
|
+
|
|
37
|
+
# Malformed Ruby format with missing method: filename.rb:123:in
|
|
38
|
+
/^(?<filename>[^:]+):(?<line>\d+):in$/
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Common paths that indicate library/gem code
|
|
42
|
+
LIBRARY_INDICATORS = [
|
|
43
|
+
'/gems/',
|
|
44
|
+
'/.bundle/',
|
|
45
|
+
'/vendor/',
|
|
46
|
+
'/ruby/',
|
|
47
|
+
'(eval)',
|
|
48
|
+
'(irb)',
|
|
49
|
+
'/lib/ruby/',
|
|
50
|
+
'/rbenv/',
|
|
51
|
+
'/rvm/',
|
|
52
|
+
'/usr/lib/ruby',
|
|
53
|
+
'/System/Library/Frameworks'
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
attr_reader :filename, :line_number, :method_name, :in_app, :raw_line
|
|
57
|
+
attr_reader :function, :module_name, :code_context, :block_info
|
|
58
|
+
|
|
59
|
+
# Backward compatibility aliases
|
|
60
|
+
alias_method :lineno, :line_number
|
|
61
|
+
alias_method :raw, :raw_line
|
|
62
|
+
|
|
63
|
+
def initialize(raw_line, in_app_patterns: [], exclude_patterns: [], load_paths: [])
|
|
64
|
+
@raw_line = raw_line.to_s.strip
|
|
65
|
+
@in_app_patterns = Array(in_app_patterns)
|
|
66
|
+
@exclude_patterns = Array(exclude_patterns)
|
|
67
|
+
@load_paths = Array(load_paths)
|
|
68
|
+
|
|
69
|
+
parse_backtrace_line
|
|
70
|
+
determine_app_status
|
|
71
|
+
normalize_paths
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
filename: @filename,
|
|
77
|
+
line_number: @line_number,
|
|
78
|
+
method: @method_name,
|
|
79
|
+
function: @function,
|
|
80
|
+
module: @module_name,
|
|
81
|
+
in_app: @in_app,
|
|
82
|
+
code_context: @code_context,
|
|
83
|
+
raw: @raw_line
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def add_code_context(processor, context_lines = 3)
|
|
88
|
+
return unless @filename && @line_number && File.exist?(@filename)
|
|
89
|
+
|
|
90
|
+
@code_context = processor.get_code_context(@filename, @line_number, context_lines)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid?
|
|
94
|
+
@filename && (@line_number.nil? || @line_number >= 0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def library_frame?
|
|
98
|
+
!@in_app
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def app_frame?
|
|
102
|
+
@in_app
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def excluded?
|
|
106
|
+
return false if @exclude_patterns.empty?
|
|
107
|
+
|
|
108
|
+
@exclude_patterns.any? do |pattern|
|
|
109
|
+
case pattern
|
|
110
|
+
when Regexp
|
|
111
|
+
@raw_line.match?(pattern)
|
|
112
|
+
when String
|
|
113
|
+
@raw_line.include?(pattern)
|
|
114
|
+
else
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def relative_filename
|
|
121
|
+
return @filename unless @filename && @load_paths.any?
|
|
122
|
+
|
|
123
|
+
# Try to make path relative to load paths
|
|
124
|
+
@load_paths.each do |load_path|
|
|
125
|
+
if @filename.start_with?(load_path)
|
|
126
|
+
relative = @filename.sub(/^#{Regexp.escape(load_path)}\/?/, '')
|
|
127
|
+
return relative unless relative.empty?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@filename
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def parse_backtrace_line
|
|
137
|
+
BACKTRACE_PATTERNS.each_with_index do |pattern, pattern_index|
|
|
138
|
+
match = @raw_line.match(pattern)
|
|
139
|
+
next unless match
|
|
140
|
+
|
|
141
|
+
@filename = match[:filename]
|
|
142
|
+
# Handle malformed line numbers - convert invalid numbers to 0
|
|
143
|
+
if match.names.include?('line') && match[:line]
|
|
144
|
+
@line_number = match[:line].match?(/^\d+$/) ? match[:line].to_i : 0
|
|
145
|
+
else
|
|
146
|
+
@line_number = nil
|
|
147
|
+
end
|
|
148
|
+
@method_name = match.names.include?('method') ? match[:method] : nil
|
|
149
|
+
@native_gem = match.names.include?('native_gem') ? match[:native_gem] : nil
|
|
150
|
+
@block_level = match.names.include?('block_level') ? match[:block_level] : nil
|
|
151
|
+
|
|
152
|
+
# Set default method name for lines without methods (top-level execution)
|
|
153
|
+
@method_name = "<main>" if @method_name.nil?
|
|
154
|
+
|
|
155
|
+
process_method_info
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Fallback: treat entire line as filename if no pattern matches
|
|
160
|
+
@filename = @raw_line
|
|
161
|
+
@line_number = nil
|
|
162
|
+
@method_name = "<main>"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def process_method_info
|
|
166
|
+
return unless @method_name
|
|
167
|
+
|
|
168
|
+
# Extract module/class and method information
|
|
169
|
+
if @method_name.include?('.')
|
|
170
|
+
# Class method: Module.method
|
|
171
|
+
parts = @method_name.split('.', 2)
|
|
172
|
+
@module_name = parts[0] if parts[0] != @method_name
|
|
173
|
+
@function = parts[1] || @method_name
|
|
174
|
+
elsif @method_name.include?('#')
|
|
175
|
+
# Instance method: Module#method
|
|
176
|
+
parts = @method_name.split('#', 2)
|
|
177
|
+
@module_name = parts[0] if parts[0] != @method_name
|
|
178
|
+
@function = parts[1] || @method_name
|
|
179
|
+
elsif @method_name.start_with?('block')
|
|
180
|
+
# Block method: process specially
|
|
181
|
+
@function = @method_name
|
|
182
|
+
process_block_info
|
|
183
|
+
else
|
|
184
|
+
@function = @method_name
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Clean up function name
|
|
188
|
+
@function = @function&.strip
|
|
189
|
+
@module_name = @module_name&.strip
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def process_block_info
|
|
193
|
+
return unless @method_name&.include?('block')
|
|
194
|
+
|
|
195
|
+
@block_info = {
|
|
196
|
+
type: :block,
|
|
197
|
+
level: @block_level,
|
|
198
|
+
in_method: nil
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Extract the method that contains the block
|
|
202
|
+
if @method_name.match(/block (?:\([^)]+\)\s+)?in (.+)/)
|
|
203
|
+
@block_info[:in_method] = $1
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def determine_app_status
|
|
208
|
+
return @in_app = false unless @filename
|
|
209
|
+
|
|
210
|
+
# Check explicit patterns first
|
|
211
|
+
if @in_app_patterns.any?
|
|
212
|
+
@in_app = @in_app_patterns.any? do |pattern|
|
|
213
|
+
case pattern
|
|
214
|
+
when Regexp
|
|
215
|
+
@filename.match?(pattern)
|
|
216
|
+
when String
|
|
217
|
+
@filename.include?(pattern)
|
|
218
|
+
else
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Default heuristics: check for library indicators
|
|
226
|
+
@in_app = !LIBRARY_INDICATORS.any? { |indicator| @filename.include?(indicator) }
|
|
227
|
+
|
|
228
|
+
# Special cases
|
|
229
|
+
@in_app = false if @native_gem # Native extensions are not app code
|
|
230
|
+
@in_app = false if @filename.start_with?('(') && @filename.end_with?(')') # Eval, irb, etc.
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def normalize_paths
|
|
234
|
+
return unless @filename
|
|
235
|
+
|
|
236
|
+
# Expand relative paths
|
|
237
|
+
if @filename.start_with?('./')
|
|
238
|
+
@filename = File.expand_path(@filename)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Handle Windows paths on Unix systems (for cross-platform stack traces)
|
|
242
|
+
if @filename.include?('\\') && !@filename.include?('/')
|
|
243
|
+
@filename = @filename.tr('\\', '/')
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Strip load paths to make traces more readable
|
|
247
|
+
if @load_paths.any?
|
|
248
|
+
original = @filename
|
|
249
|
+
@filename = relative_filename
|
|
250
|
+
|
|
251
|
+
# Keep absolute path if relative didn't work well
|
|
252
|
+
if @filename.empty? || @filename == '.'
|
|
253
|
+
@filename = original
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|