airbrake-ruby 4.8.0 → 4.11.1
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/lib/airbrake-ruby.rb +101 -25
- data/lib/airbrake-ruby/async_sender.rb +3 -3
- data/lib/airbrake-ruby/backtrace.rb +2 -2
- data/lib/airbrake-ruby/benchmark.rb +1 -1
- data/lib/airbrake-ruby/code_hunk.rb +1 -1
- data/lib/airbrake-ruby/config.rb +1 -1
- data/lib/airbrake-ruby/config/validator.rb +3 -3
- data/lib/airbrake-ruby/deploy_notifier.rb +1 -1
- data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +2 -2
- data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +2 -2
- data/lib/airbrake-ruby/filters/keys_filter.rb +1 -1
- data/lib/airbrake-ruby/filters/sql_filter.rb +3 -3
- data/lib/airbrake-ruby/filters/thread_filter.rb +1 -1
- data/lib/airbrake-ruby/grouppable.rb +12 -0
- data/lib/airbrake-ruby/inspectable.rb +2 -2
- data/lib/airbrake-ruby/mergeable.rb +12 -0
- data/lib/airbrake-ruby/notice.rb +7 -7
- data/lib/airbrake-ruby/notice_notifier.rb +3 -2
- data/lib/airbrake-ruby/performance_breakdown.rb +12 -6
- data/lib/airbrake-ruby/performance_notifier.rb +69 -22
- data/lib/airbrake-ruby/query.rb +15 -11
- data/lib/airbrake-ruby/queue.rb +56 -0
- data/lib/airbrake-ruby/request.rb +14 -12
- data/lib/airbrake-ruby/stat.rb +1 -1
- data/lib/airbrake-ruby/version.rb +1 -1
- data/spec/airbrake_spec.rb +135 -45
- data/spec/async_sender_spec.rb +4 -4
- data/spec/backtrace_spec.rb +18 -18
- data/spec/code_hunk_spec.rb +9 -9
- data/spec/config/validator_spec.rb +5 -5
- data/spec/config_spec.rb +5 -9
- data/spec/deploy_notifier_spec.rb +2 -2
- data/spec/filter_chain_spec.rb +1 -1
- data/spec/filters/dependency_filter_spec.rb +1 -1
- data/spec/filters/gem_root_filter_spec.rb +5 -5
- data/spec/filters/git_last_checkout_filter_spec.rb +1 -1
- data/spec/filters/git_repository_filter.rb +1 -1
- data/spec/filters/git_revision_filter_spec.rb +10 -10
- data/spec/filters/keys_blacklist_spec.rb +22 -22
- data/spec/filters/keys_whitelist_spec.rb +21 -21
- data/spec/filters/root_directory_filter_spec.rb +5 -5
- data/spec/filters/sql_filter_spec.rb +53 -55
- data/spec/filters/system_exit_filter_spec.rb +1 -1
- data/spec/filters/thread_filter_spec.rb +28 -28
- data/spec/fixtures/project_root/code.rb +9 -9
- data/spec/notice_notifier/options_spec.rb +12 -12
- data/spec/notice_notifier_spec.rb +18 -18
- data/spec/notice_spec.rb +5 -5
- data/spec/performance_breakdown_spec.rb +11 -0
- data/spec/performance_notifier_spec.rb +243 -72
- data/spec/query_spec.rb +11 -1
- data/spec/queue_spec.rb +21 -0
- data/spec/request_spec.rb +11 -1
- data/spec/response_spec.rb +8 -8
- data/spec/spec_helper.rb +2 -2
- data/spec/stat_spec.rb +2 -2
- data/spec/sync_sender_spec.rb +12 -12
- data/spec/tdigest_spec.rb +6 -6
- data/spec/thread_pool_spec.rb +5 -5
- data/spec/timed_trace_spec.rb +1 -1
- data/spec/truncator_spec.rb +12 -12
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6b9d78ba8789ccfa27f139a5ad0fef6b544da9bde4239dfda22cc543493bdb45
|
4
|
+
data.tar.gz: c32c7e64425037221491c64de8e22d548815929ec6e3a0289aad3c74f8ffedc7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 711d058e48af32390e7b92243171014122b5e454cea35afd19ca72e5fe39d04b919f0cbc95dfdf23685a3114cac14430310eb5a0c785c263d85a52ca26bab861
|
7
|
+
data.tar.gz: 300176c0bab2a6ddcb4b3453069be19ef5a477021ca10f68256edabc97c9d0d15b3b4a5659f2676e53071f89229b59c90ac6d0f1016d953342af8d9fc8b27581
|
data/lib/airbrake-ruby.rb
CHANGED
@@ -9,6 +9,8 @@ require 'time'
|
|
9
9
|
require 'airbrake-ruby/version'
|
10
10
|
require 'airbrake-ruby/loggable'
|
11
11
|
require 'airbrake-ruby/stashable'
|
12
|
+
require 'airbrake-ruby/mergeable'
|
13
|
+
require 'airbrake-ruby/grouppable'
|
12
14
|
require 'airbrake-ruby/config'
|
13
15
|
require 'airbrake-ruby/config/validator'
|
14
16
|
require 'airbrake-ruby/promise'
|
@@ -52,6 +54,7 @@ require 'airbrake-ruby/performance_breakdown'
|
|
52
54
|
require 'airbrake-ruby/benchmark'
|
53
55
|
require 'airbrake-ruby/monotonic_time'
|
54
56
|
require 'airbrake-ruby/timed_trace'
|
57
|
+
require 'airbrake-ruby/queue'
|
55
58
|
|
56
59
|
# Airbrake is a thin wrapper around instances of the notifier classes (such as
|
57
60
|
# notice, performance & deploy notifiers). It creates a way to access them via a
|
@@ -69,6 +72,7 @@ require 'airbrake-ruby/timed_trace'
|
|
69
72
|
#
|
70
73
|
# @since v1.0.0
|
71
74
|
# @api public
|
75
|
+
# rubocop:disable Metrics/ModuleLength
|
72
76
|
module Airbrake
|
73
77
|
# The general error that this library uses when it wants to raise.
|
74
78
|
Error = Class.new(StandardError)
|
@@ -131,7 +135,7 @@ module Airbrake
|
|
131
135
|
# @return [Boolean] true if the notifier was configured, false otherwise
|
132
136
|
# @since v2.3.0
|
133
137
|
def configured?
|
134
|
-
notice_notifier.configured?
|
138
|
+
@notice_notifier && @notice_notifier.configured?
|
135
139
|
end
|
136
140
|
|
137
141
|
# Sends an exception to Airbrake asynchronously.
|
@@ -251,10 +255,17 @@ module Airbrake
|
|
251
255
|
# Airbrake.notify('App crashed!') #=> raises Airbrake::Error
|
252
256
|
#
|
253
257
|
# @return [void]
|
258
|
+
# rubocop:disable Style/GuardClause, Style/IfUnlessModifier
|
254
259
|
def close
|
255
|
-
notice_notifier
|
256
|
-
|
260
|
+
if defined?(@notice_notifier) && @notice_notifier
|
261
|
+
@notice_notifier.close
|
262
|
+
end
|
263
|
+
|
264
|
+
if defined?(@performance_notifier) && @performance_notifier
|
265
|
+
@performance_notifier.close
|
266
|
+
end
|
257
267
|
end
|
268
|
+
# rubocop:enable Style/GuardClause, Style/IfUnlessModifier
|
258
269
|
|
259
270
|
# Pings the Airbrake Deploy API endpoint about the occurred deploy.
|
260
271
|
#
|
@@ -317,9 +328,8 @@ module Airbrake
|
|
317
328
|
notice_notifier.merge_context(context)
|
318
329
|
end
|
319
330
|
|
320
|
-
# Increments request statistics of a certain +route+
|
321
|
-
#
|
322
|
-
# +status_code+.
|
331
|
+
# Increments request statistics of a certain +route+ invoked with +method+,
|
332
|
+
# which returned +status_code+.
|
323
333
|
#
|
324
334
|
# After a certain amount of time (n seconds) the aggregated route
|
325
335
|
# information will be sent to Airbrake.
|
@@ -332,8 +342,7 @@ module Airbrake
|
|
332
342
|
# func: 'do_stuff',
|
333
343
|
# file: 'app/models/foo.rb',
|
334
344
|
# line: 452,
|
335
|
-
#
|
336
|
-
# end_time: Time.now
|
345
|
+
# timing: 123.45 # ms
|
337
346
|
# )
|
338
347
|
#
|
339
348
|
# @param [Hash{Symbol=>Object}] request_info
|
@@ -347,8 +356,8 @@ module Airbrake
|
|
347
356
|
# called the query (optional)
|
348
357
|
# @option request_info [Integer] :line The line that executes the query
|
349
358
|
# (optional)
|
350
|
-
# @option request_info [
|
351
|
-
#
|
359
|
+
# @option request_info [Float] :timing How much time it took to process the
|
360
|
+
# request (in ms)
|
352
361
|
# @param [Hash] stash What needs to be appeneded to the stash, so it's
|
353
362
|
# available in filters
|
354
363
|
# @return [void]
|
@@ -360,9 +369,18 @@ module Airbrake
|
|
360
369
|
performance_notifier.notify(request)
|
361
370
|
end
|
362
371
|
|
363
|
-
#
|
364
|
-
# +
|
365
|
-
#
|
372
|
+
# Synchronously increments request statistics of a certain +route+ invoked
|
373
|
+
# with +method+, which returned +status_code+.
|
374
|
+
# @since v4.10.0
|
375
|
+
# @see .notify_request
|
376
|
+
def notify_request_sync(request_info, stash = {})
|
377
|
+
request = Request.new(request_info)
|
378
|
+
request.stash.merge!(stash)
|
379
|
+
performance_notifier.notify_sync(request)
|
380
|
+
end
|
381
|
+
|
382
|
+
# Increments SQL statistics of a certain +query+. When +method+ and +route+
|
383
|
+
# are provided, the query is grouped by these parameters.
|
366
384
|
#
|
367
385
|
# After a certain amount of time (n seconds) the aggregated query
|
368
386
|
# information will be sent to Airbrake.
|
@@ -372,18 +390,17 @@ module Airbrake
|
|
372
390
|
# method: 'GET',
|
373
391
|
# route: '/things',
|
374
392
|
# query: 'SELECT * FROM things',
|
375
|
-
#
|
376
|
-
# end_time: Time.now
|
393
|
+
# timing: 123.45 # ms
|
377
394
|
# )
|
378
395
|
#
|
379
396
|
# @param [Hash{Symbol=>Object}] query_info
|
380
|
-
# @option
|
397
|
+
# @option query_info [String] :method The HTTP method that triggered this
|
381
398
|
# SQL query (optional)
|
382
|
-
# @option
|
399
|
+
# @option query_info [String] :route The route that triggered this SQL
|
383
400
|
# query (optional)
|
384
|
-
# @option
|
385
|
-
# @option
|
386
|
-
#
|
401
|
+
# @option query_info [String] :query The query that was executed
|
402
|
+
# @option query_info [Float] :timing How much time it took to process the
|
403
|
+
# query (in ms)
|
387
404
|
# @param [Hash] stash What needs to be appeneded to the stash, so it's
|
388
405
|
# available in filters
|
389
406
|
# @return [void]
|
@@ -395,6 +412,17 @@ module Airbrake
|
|
395
412
|
performance_notifier.notify(query)
|
396
413
|
end
|
397
414
|
|
415
|
+
# Synchronously increments SQL statistics of a certain +query+. When
|
416
|
+
# +method+ and +route+ are provided, the query is grouped by these
|
417
|
+
# parameters.
|
418
|
+
# @since v4.10.0
|
419
|
+
# @see .notify_query
|
420
|
+
def notify_query_sync(query_info, stash = {})
|
421
|
+
query = Query.new(query_info)
|
422
|
+
query.stash.merge!(stash)
|
423
|
+
performance_notifier.notify_sync(query)
|
424
|
+
end
|
425
|
+
|
398
426
|
# Increments performance breakdown statistics of a certain route.
|
399
427
|
#
|
400
428
|
# @example
|
@@ -403,8 +431,7 @@ module Airbrake
|
|
403
431
|
# route: '/thing/:id/create',
|
404
432
|
# response_type: 'json',
|
405
433
|
# groups: { db: 24.0, view: 0.4 }, # ms
|
406
|
-
#
|
407
|
-
# end_time: Time.now
|
434
|
+
# timing: 123.45 # ms
|
408
435
|
# )
|
409
436
|
#
|
410
437
|
# @param [Hash{Symbol=>Object}] breakdown_info
|
@@ -412,7 +439,8 @@ module Airbrake
|
|
412
439
|
# @option breakdown_info [String] :route
|
413
440
|
# @option breakdown_info [String] :response_type
|
414
441
|
# @option breakdown_info [Array<Hash{Symbol=>Float}>] :groups
|
415
|
-
# @option breakdown_info [
|
442
|
+
# @option breakdown_info [Float] :timing How much time it took to process
|
443
|
+
# the performance breakdown (in ms)
|
416
444
|
# @param [Hash] stash What needs to be appeneded to the stash, so it's
|
417
445
|
# available in filters
|
418
446
|
# @return [void]
|
@@ -423,6 +451,53 @@ module Airbrake
|
|
423
451
|
performance_notifier.notify(performance_breakdown)
|
424
452
|
end
|
425
453
|
|
454
|
+
# Increments performance breakdown statistics of a certain route
|
455
|
+
# synchronously.
|
456
|
+
# @since v4.10.0
|
457
|
+
# @see .notify_performance_breakdown
|
458
|
+
def notify_performance_breakdown_sync(breakdown_info, stash = {})
|
459
|
+
performance_breakdown = PerformanceBreakdown.new(breakdown_info)
|
460
|
+
performance_breakdown.stash.merge!(stash)
|
461
|
+
performance_notifier.notify_sync(performance_breakdown)
|
462
|
+
end
|
463
|
+
|
464
|
+
# Increments statistics of a certain queue (worker).
|
465
|
+
#
|
466
|
+
# @example
|
467
|
+
# Airbrake.notify_queue(
|
468
|
+
# queue: 'emails',
|
469
|
+
# error_count: 1,
|
470
|
+
# groups: { redis: 24.0, sql: 0.4 } # ms
|
471
|
+
# )
|
472
|
+
#
|
473
|
+
# @param [Hash{Symbol=>Object}] queue_info
|
474
|
+
# @option queue_info [String] :queue The name of the queue/worker
|
475
|
+
# @option queue_info [Integer] :error_count How many times this worker
|
476
|
+
# failed
|
477
|
+
# @option queue_info [Array<Hash{Symbol=>Float}>] :groups Where the job
|
478
|
+
# spent its time
|
479
|
+
# @option breakdown_info [Float] :timing How much time it took to process
|
480
|
+
# the queue (in ms)
|
481
|
+
# @param [Hash] stash What needs to be appended to the stash, so it's
|
482
|
+
# available in filters
|
483
|
+
# @return [void]
|
484
|
+
# @since v4.9.0
|
485
|
+
# @see .notify_queue_sync
|
486
|
+
def notify_queue(queue_info, stash = {})
|
487
|
+
queue = Queue.new(queue_info)
|
488
|
+
queue.stash.merge!(stash)
|
489
|
+
performance_notifier.notify(queue)
|
490
|
+
end
|
491
|
+
|
492
|
+
# Increments statistics of a certain queue (worker) synchronously.
|
493
|
+
# @since v4.10.0
|
494
|
+
# @see .notify_queue
|
495
|
+
def notify_queue_sync(queue_info, stash = {})
|
496
|
+
queue = Queue.new(queue_info)
|
497
|
+
queue.stash.merge!(stash)
|
498
|
+
performance_notifier.notify_sync(queue)
|
499
|
+
end
|
500
|
+
|
426
501
|
# Runs a callback before {.notify_request} or {.notify_query} kicks in. This
|
427
502
|
# is useful if you want to ignore specific resources or filter the data the
|
428
503
|
# resource contains.
|
@@ -480,7 +555,7 @@ module Airbrake
|
|
480
555
|
# @return [void]
|
481
556
|
# @since v4.2.2
|
482
557
|
def reset
|
483
|
-
close
|
558
|
+
close
|
484
559
|
|
485
560
|
self.performance_notifier = PerformanceNotifier.new
|
486
561
|
self.notice_notifier = NoticeNotifier.new
|
@@ -506,10 +581,11 @@ module Airbrake
|
|
506
581
|
Airbrake::Filters::RootDirectoryFilter,
|
507
582
|
Airbrake::Filters::GitRevisionFilter,
|
508
583
|
Airbrake::Filters::GitRepositoryFilter,
|
509
|
-
Airbrake::Filters::GitLastCheckoutFilter
|
584
|
+
Airbrake::Filters::GitLastCheckoutFilter,
|
510
585
|
].each do |filter|
|
511
586
|
notice_notifier.add_filter(filter.new(config.root_directory))
|
512
587
|
end
|
513
588
|
end
|
514
589
|
end
|
515
590
|
end
|
591
|
+
# rubocop:enable Metrics/ModuleLength
|
@@ -54,7 +54,7 @@ module Airbrake
|
|
54
54
|
ThreadPool.new(
|
55
55
|
worker_size: @config.workers,
|
56
56
|
queue_size: @config.queue_size,
|
57
|
-
block: proc { |args| sender.send(*args) }
|
57
|
+
block: proc { |args| sender.send(*args) },
|
58
58
|
)
|
59
59
|
end
|
60
60
|
end
|
@@ -71,8 +71,8 @@ module Airbrake
|
|
71
71
|
message: error[:message],
|
72
72
|
backtrace: error[:backtrace].map do |line|
|
73
73
|
"#{line[:file]}:#{line[:line]} in `#{line[:function]}'"
|
74
|
-
end.join("\n")
|
75
|
-
)
|
74
|
+
end.join("\n"),
|
75
|
+
),
|
76
76
|
)
|
77
77
|
promise.reject("AsyncSender has reached its capacity of #{@config.queue_size}")
|
78
78
|
end
|
@@ -147,13 +147,13 @@ module Airbrake
|
|
147
147
|
return {
|
148
148
|
file: match[:file],
|
149
149
|
line: (Integer(match[:line]) if match[:line]),
|
150
|
-
function: match[:function]
|
150
|
+
function: match[:function],
|
151
151
|
}
|
152
152
|
end
|
153
153
|
|
154
154
|
logger.error(
|
155
155
|
"can't parse '#{stackframe}' (please file an issue so we can fix " \
|
156
|
-
"it: https://github.com/airbrake/airbrake-ruby/issues/new)"
|
156
|
+
"it: https://github.com/airbrake/airbrake-ruby/issues/new)",
|
157
157
|
)
|
158
158
|
{ file: nil, line: nil, function: stackframe }
|
159
159
|
end
|
@@ -30,7 +30,7 @@ module Airbrake
|
|
30
30
|
Airbrake::FileCache[file] ||= File.foreach(file)
|
31
31
|
rescue StandardError => ex
|
32
32
|
logger.error(
|
33
|
-
"#{self.class.name}: can't read code hunk for #{file}: #{ex}"
|
33
|
+
"#{self.class.name}: can't read code hunk for #{file}: #{ex}",
|
34
34
|
)
|
35
35
|
nil
|
36
36
|
end
|
data/lib/airbrake-ruby/config.rb
CHANGED
@@ -29,7 +29,7 @@ module Airbrake
|
|
29
29
|
return promise.reject(
|
30
30
|
"the 'environment' option must be configured " \
|
31
31
|
"with a Symbol (or String), but '#{config.environment.class}' was " \
|
32
|
-
"provided: #{config.environment}"
|
32
|
+
"provided: #{config.environment}",
|
33
33
|
)
|
34
34
|
end
|
35
35
|
|
@@ -46,7 +46,7 @@ module Airbrake
|
|
46
46
|
|
47
47
|
if ignored_environment?(config)
|
48
48
|
return promise.reject(
|
49
|
-
"current environment '#{config.environment}' is ignored"
|
49
|
+
"current environment '#{config.environment}' is ignored",
|
50
50
|
)
|
51
51
|
end
|
52
52
|
|
@@ -74,7 +74,7 @@ module Airbrake
|
|
74
74
|
if config.ignore_environments.any? && config.environment.nil?
|
75
75
|
config.logger.warn(
|
76
76
|
"#{LOG_LABEL} the 'environment' option is not set, " \
|
77
|
-
"'ignore_environments' has no effect"
|
77
|
+
"'ignore_environments' has no effect",
|
78
78
|
)
|
79
79
|
end
|
80
80
|
|
@@ -22,13 +22,13 @@ module Airbrake
|
|
22
22
|
attributes = exception.to_airbrake
|
23
23
|
rescue StandardError => ex
|
24
24
|
logger.error(
|
25
|
-
"#{LOG_LABEL} #{exception.class}#to_airbrake failed. #{ex.class}: #{ex}"
|
25
|
+
"#{LOG_LABEL} #{exception.class}#to_airbrake failed. #{ex.class}: #{ex}",
|
26
26
|
)
|
27
27
|
end
|
28
28
|
|
29
29
|
unless attributes.is_a?(Hash)
|
30
30
|
logger.error(
|
31
|
-
"#{LOG_LABEL} #{self.class}: wanted Hash, got #{attributes.class}"
|
31
|
+
"#{LOG_LABEL} #{self.class}: wanted Hash, got #{attributes.class}",
|
32
32
|
)
|
33
33
|
return
|
34
34
|
end
|
@@ -52,7 +52,7 @@ module Airbrake
|
|
52
52
|
parts = line.chomp.split("\t").first.split(' ')
|
53
53
|
if parts.size < MIN_HEAD_COLS
|
54
54
|
logger.error(
|
55
|
-
"#{LOG_LABEL} Airbrake::#{self.class.name}: can't parse line: #{line}"
|
55
|
+
"#{LOG_LABEL} Airbrake::#{self.class.name}: can't parse line: #{line}",
|
56
56
|
)
|
57
57
|
return
|
58
58
|
end
|
@@ -62,7 +62,7 @@ module Airbrake
|
|
62
62
|
username: author[0..1].join(' '),
|
63
63
|
email: parts[-3][1..-2],
|
64
64
|
revision: parts[1],
|
65
|
-
time: timestamp(parts[-2].to_i)
|
65
|
+
time: timestamp(parts[-2].to_i),
|
66
66
|
}
|
67
67
|
end
|
68
68
|
# rubocop:enable Metrics/AbcSize
|
@@ -64,7 +64,7 @@ module Airbrake
|
|
64
64
|
cassandra: %i[
|
65
65
|
single_quotes uuids numeric_literals boolean_literals
|
66
66
|
hexadecimal_literals comments multi_line_comments
|
67
|
-
].freeze
|
67
|
+
].freeze,
|
68
68
|
}.freeze
|
69
69
|
|
70
70
|
# @return [Hash{Symbol=>Regexp}] a set of regexps to check for unmatches
|
@@ -76,7 +76,7 @@ module Airbrake
|
|
76
76
|
sqlite: %r{'|/\*|\*/},
|
77
77
|
cassandra: %r{'|/\*|\*/},
|
78
78
|
oracle: %r{'|/\*|\*/},
|
79
|
-
oracle_enhanced: %r{'|/\*|\*/}
|
79
|
+
oracle_enhanced: %r{'|/\*|\*/},
|
80
80
|
}.freeze
|
81
81
|
|
82
82
|
# @return [Array<Regexp>] the list of queries to be ignored
|
@@ -89,7 +89,7 @@ module Airbrake
|
|
89
89
|
/FROM pg_attribute/i,
|
90
90
|
/FROM pg_index/i,
|
91
91
|
/FROM pg_class/i,
|
92
|
-
/FROM pg_type/i
|
92
|
+
/FROM pg_type/i,
|
93
93
|
].freeze
|
94
94
|
|
95
95
|
def initialize(dialect)
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Airbrake
|
2
|
+
# Grouppable adds the `#groups` method, so that we don't need to define it in
|
3
|
+
# all of performance models every time we add a model without groups.
|
4
|
+
#
|
5
|
+
# @since 4.9.0
|
6
|
+
# @api private
|
7
|
+
module Grouppable
|
8
|
+
def groups
|
9
|
+
{}
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|