trifle-stats 1.3.0 → 1.6.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer.json +1 -1
  3. data/.devops/docker/codespaces/Dockerfile +1 -1
  4. data/.devops/docker/environment/Dockerfile +3 -2
  5. data/.devops/docker/gitpod/base/.p10k.zsh +1626 -0
  6. data/.devops/docker/gitpod/base/.zshrc +13 -0
  7. data/.devops/docker/gitpod/base/Dockerfile +9 -32
  8. data/.devops/docker/local/Dockerfile +1 -0
  9. data/.devops/docker/local/docker-compose.yml +30 -0
  10. data/.github/workflows/ruby.yml +85 -6
  11. data/.gitignore +3 -1
  12. data/.ruby-version +1 -1
  13. data/.tool-versions +1 -1
  14. data/Gemfile +4 -0
  15. data/Gemfile.lock +11 -8
  16. data/README.md +31 -0
  17. data/lib/trifle/stats/aggregator/avg.rb +32 -0
  18. data/lib/trifle/stats/aggregator/max.rb +29 -0
  19. data/lib/trifle/stats/aggregator/min.rb +29 -0
  20. data/lib/trifle/stats/aggregator/sum.rb +29 -0
  21. data/lib/trifle/stats/driver/mongo.rb +81 -28
  22. data/lib/trifle/stats/driver/postgres.rb +14 -3
  23. data/lib/trifle/stats/driver/process.rb +12 -0
  24. data/lib/trifle/stats/driver/redis.rb +19 -5
  25. data/lib/trifle/stats/driver/sqlite.rb +15 -3
  26. data/lib/trifle/stats/formatter/category.rb +32 -0
  27. data/lib/trifle/stats/formatter/timeline.rb +29 -0
  28. data/lib/trifle/stats/mixins/packer.rb +16 -1
  29. data/lib/trifle/stats/nocturnal.rb +24 -0
  30. data/lib/trifle/stats/operations/status/beam.rb +31 -0
  31. data/lib/trifle/stats/operations/status/scan.rb +35 -0
  32. data/lib/trifle/stats/operations/timeseries/classify.rb +1 -1
  33. data/lib/trifle/stats/operations/timeseries/increment.rb +1 -1
  34. data/lib/trifle/stats/operations/timeseries/set.rb +1 -1
  35. data/lib/trifle/stats/operations/timeseries/values.rb +25 -7
  36. data/lib/trifle/stats/series.rb +64 -0
  37. data/lib/trifle/stats/transponder/average.rb +31 -0
  38. data/lib/trifle/stats/transponder/ratio.rb +31 -0
  39. data/lib/trifle/stats/transponder/standard_deviation.rb +35 -0
  40. data/lib/trifle/stats/version.rb +1 -1
  41. data/lib/trifle/stats.rb +36 -3
  42. data/trifle-stats.gemspec +1 -0
  43. metadata +20 -3
@@ -12,6 +12,10 @@ module Trifle
12
12
  @separator = '::'
13
13
  end
14
14
 
15
+ def description
16
+ "#{self.class.name}(J)"
17
+ end
18
+
15
19
  def inc(keys:, **values)
16
20
  keys.map do |key|
17
21
  self.class.pack(hash: values).each do |k, c|
@@ -39,6 +43,14 @@ module Trifle
39
43
  )
40
44
  end
41
45
  end
46
+
47
+ def ping(*)
48
+ []
49
+ end
50
+
51
+ def scan(*)
52
+ []
53
+ end
42
54
  end
43
55
  end
44
56
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'redis'
4
3
  require_relative '../mixins/packer'
5
4
 
6
5
  module Trifle
@@ -10,15 +9,20 @@ module Trifle
10
9
  include Mixins::Packer
11
10
  attr_accessor :client, :prefix, :separator
12
11
 
13
- def initialize(client = ::Redis.current, prefix: 'trfl')
12
+ def initialize(client, prefix: 'trfl')
14
13
  @client = client
15
14
  @prefix = prefix
16
15
  @separator = '::'
17
16
  end
18
17
 
18
+ def description
19
+ "#{self.class.name}(J)"
20
+ end
21
+
19
22
  def inc(keys:, **values)
20
23
  keys.map do |key|
21
- pkey = ([prefix] + key).join(separator)
24
+ key.prefix = prefix
25
+ pkey = key.join(separator)
22
26
 
23
27
  self.class.pack(hash: values).each do |k, c|
24
28
  client.hincrby(pkey, k, c)
@@ -28,7 +32,8 @@ module Trifle
28
32
 
29
33
  def set(keys:, **values)
30
34
  keys.map do |key|
31
- pkey = ([prefix] + key).join(separator)
35
+ key.prefix = prefix
36
+ pkey = key.join(separator)
32
37
 
33
38
  client.hmset(pkey, *self.class.pack(hash: values))
34
39
  end
@@ -36,13 +41,22 @@ module Trifle
36
41
 
37
42
  def get(keys:)
38
43
  keys.map do |key|
39
- pkey = ([prefix] + key).join(separator)
44
+ key.prefix = prefix
45
+ pkey = key.join(separator)
40
46
 
41
47
  self.class.unpack(
42
48
  hash: client.hgetall(pkey)
43
49
  )
44
50
  end
45
51
  end
52
+
53
+ def ping(*)
54
+ []
55
+ end
56
+
57
+ def scan(*)
58
+ []
59
+ end
46
60
  end
47
61
  end
48
62
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'sqlite3'
3
+ require 'json'
4
4
  require_relative '../mixins/packer'
5
5
 
6
6
  module Trifle
@@ -21,6 +21,10 @@ module Trifle
21
21
  client.execute("CREATE UNIQUE INDEX idx_#{table_name}_key ON #{table_name} (key);")
22
22
  end
23
23
 
24
+ def description
25
+ "#{self.class.name}(J)"
26
+ end
27
+
24
28
  def inc(keys:, **values)
25
29
  data = self.class.pack(hash: values)
26
30
  client.transaction do |c|
@@ -53,7 +57,7 @@ module Trifle
53
57
  <<-SQL
54
58
  INSERT INTO #{table_name} (key, data) values('#{key}', json('#{data.to_json}'))
55
59
  ON CONFLICT (key) DO UPDATE SET data =
56
- #{data.inject('data') { |o, (k, v)| "json_set(#{o}, '$.#{k}', #{v})" }};
60
+ #{data.inject('data') { |o, (k, v)| "json_set(#{o}, '$.#{k}', #{v.to_json})" }};
57
61
  SQL
58
62
  end
59
63
 
@@ -62,7 +66,7 @@ module Trifle
62
66
  data = get_all(keys: pkeys)
63
67
  map = data.inject({}) { |o, d| o.merge(d[:key] => d[:data]) }
64
68
 
65
- pkeys.map { |pkey| map.fetch(pkey, {}) }
69
+ pkeys.map { |pkey| self.class.unpack(hash: map.fetch(pkey, {})) }
66
70
  end
67
71
 
68
72
  def get_all(keys:)
@@ -80,6 +84,14 @@ module Trifle
80
84
  SELECT key, data FROM #{table_name} WHERE key IN ('#{keys.join("', '")}');
81
85
  SQL
82
86
  end
87
+
88
+ def ping(*)
89
+ []
90
+ end
91
+
92
+ def scan(*)
93
+ []
94
+ end
83
95
  end
84
96
  end
85
97
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Formatter
6
+ class Category
7
+ Trifle::Stats::Series.register_formatter(:category, self)
8
+
9
+ def format(series:, path:, slices: 1, &block)
10
+ return [] if series[:at].empty?
11
+
12
+ keys = path.split('.')
13
+ result = series[:at].zip(series[:values].map { |v| v.dig(*keys) || {} })
14
+ sliced(result: result, slices: slices, block: block)
15
+ end
16
+
17
+ private
18
+
19
+ def sliced(result:, slices:, block: nil) # rubocop:disable Metrics/AbcSize
20
+ result[(result.count - (result.count / slices * slices))..].each_slice(result.count / slices).map do |slice|
21
+ slice.each_with_object(Hash.new(0)) do |(_at, data), map|
22
+ data.each do |key, value|
23
+ k, v = block ? block.call(key, value) : [key.to_s, value.to_f]
24
+ map[k] += v
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Formatter
6
+ class Timeline
7
+ Trifle::Stats::Series.register_formatter(:timeline, self)
8
+
9
+ def format(series:, path:, slices: 1, &block)
10
+ return [] if series[:at].empty?
11
+
12
+ keys = path.split('.')
13
+ result = series[:at].zip(series[:values].map { |v| v.dig(*keys) })
14
+ sliced(result: result, slices: slices, block: block)
15
+ end
16
+
17
+ private
18
+
19
+ def sliced(result:, slices:, block: nil)
20
+ result[(result.count - (result.count / slices * slices))..].each_slice(result.count / slices).map do |slice|
21
+ slice.map do |at, value|
22
+ block ? block.call(at, value) : [at, value.to_f]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bigdecimal'
4
+
3
5
  module Trifle
4
6
  module Stats
5
7
  module Mixins
@@ -26,7 +28,7 @@ module Trifle
26
28
  hash.inject({}) do |out, (key, v)|
27
29
  deep_merge(
28
30
  out,
29
- key.split('.').reverse.inject(v.to_i) { |o, k| { k => o } }
31
+ key.split('.').reverse.inject(v) { |o, k| { k => o } }
30
32
  )
31
33
  end
32
34
  end
@@ -46,6 +48,19 @@ module Trifle
46
48
  end
47
49
  end
48
50
  end
51
+
52
+ def normalize(object)
53
+ case object
54
+ when Hash
55
+ object.each_with_object({}) do |(key, value), result|
56
+ result[key.to_s] = normalize(value)
57
+ end
58
+ when Array
59
+ object.map { |v| normalize(v) }
60
+ else
61
+ BigDecimal(object)
62
+ end
63
+ end
49
64
  end
50
65
  end
51
66
  end
@@ -3,6 +3,30 @@
3
3
  module Trifle
4
4
  module Stats
5
5
  class Nocturnal # rubocop:disable Metrics/ClassLength
6
+ class Key
7
+ attr_reader :key, :range, :at
8
+ attr_accessor :prefix
9
+
10
+ def initialize(key:, range: nil, at: nil)
11
+ @prefix = nil
12
+ @key = key
13
+ @range = range
14
+ @at = at
15
+ end
16
+
17
+ def join(separator)
18
+ [prefix, key, range, at&.to_i].compact.join(separator)
19
+ end
20
+
21
+ def identifier(separator)
22
+ if separator
23
+ { key: join(separator) }
24
+ else
25
+ { key: key, range: range, at: at }.compact
26
+ end
27
+ end
28
+ end
29
+
6
30
  DAYS_INTO_WEEK = {
7
31
  sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
8
32
  thursday: 4, friday: 5, saturday: 6
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ module Operations
6
+ module Status
7
+ class Beam
8
+ attr_reader :key, :at, :values
9
+
10
+ def initialize(**keywords)
11
+ @key = keywords.fetch(:key)
12
+ @at = keywords.fetch(:at)
13
+ @values = keywords.fetch(:values)
14
+ @config = keywords[:config]
15
+ end
16
+
17
+ def config
18
+ @config || Trifle::Stats.default
19
+ end
20
+
21
+ def perform
22
+ config.driver.ping(
23
+ key: Nocturnal::Key.new(key: key, at: at),
24
+ **values
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ module Operations
6
+ module Status
7
+ class Scan
8
+ attr_reader :key
9
+
10
+ def initialize(**keywords)
11
+ @key = keywords.fetch(:key)
12
+ @config = keywords[:config]
13
+ end
14
+
15
+ def config
16
+ @config || Trifle::Stats.default
17
+ end
18
+
19
+ def data
20
+ @data ||= config.driver.scan(
21
+ key: Nocturnal::Key.new(key: key)
22
+ )
23
+ end
24
+
25
+ def perform
26
+ {
27
+ at: data.first,
28
+ values: data.last
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -32,7 +32,7 @@ module Trifle
32
32
 
33
33
  def key_for(range:)
34
34
  at = Nocturnal.new(@at, config: config).send(range)
35
- [key, range, at.to_i]
35
+ Nocturnal::Key.new(key: key, range: range, at: at)
36
36
  end
37
37
 
38
38
  def perform
@@ -20,7 +20,7 @@ module Trifle
20
20
 
21
21
  def key_for(range:)
22
22
  at = Nocturnal.new(@at, config: config).send(range)
23
- [key, range, at.to_i]
23
+ Nocturnal::Key.new(key: key, range: range, at: at)
24
24
  end
25
25
 
26
26
  def perform
@@ -20,7 +20,7 @@ module Trifle
20
20
 
21
21
  def key_for(range:)
22
22
  at = Nocturnal.new(@at, config: config).send(range)
23
- [key, range, at.to_i]
23
+ Nocturnal::Key.new(key: key, range: range, at: at)
24
24
  end
25
25
 
26
26
  def perform
@@ -13,6 +13,7 @@ module Trifle
13
13
  @to = keywords.fetch(:to)
14
14
  @range = keywords.fetch(:range)
15
15
  @config = keywords[:config]
16
+ @skip_blanks = keywords[:skip_blanks]
16
17
  end
17
18
 
18
19
  def config
@@ -20,19 +21,36 @@ module Trifle
20
21
  end
21
22
 
22
23
  def timeline
23
- Nocturnal.timeline(from: @from, to: @to, range: range)
24
+ @timeline ||= Nocturnal.timeline(from: @from, to: @to, range: range)
24
25
  end
25
26
 
26
- def perform
27
+ def data
28
+ @data ||= config.driver.get(
29
+ keys: timeline.map do |at|
30
+ Nocturnal::Key.new(key: key, range: range, at: at)
31
+ end
32
+ )
33
+ end
34
+
35
+ def clean_values
36
+ timeline.each_with_object({ at: [], values: [] }).with_index do |(_at, res), idx|
37
+ next if data[idx].empty?
38
+
39
+ res[:at] << timeline[idx]
40
+ res[:values] << data[idx]
41
+ end
42
+ end
43
+
44
+ def values
27
45
  {
28
46
  at: timeline,
29
- values: config.driver.get(
30
- keys: timeline.map do |at|
31
- [key, range, at.to_i]
32
- end
33
- )
47
+ values: data
34
48
  }
35
49
  end
50
+
51
+ def perform
52
+ @skip_blanks ? clean_values : values
53
+ end
36
54
  end
37
55
  end
38
56
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Series
6
+ include Trifle::Stats::Mixins::Packer
7
+
8
+ attr_accessor :series
9
+
10
+ def initialize(series)
11
+ @series = series
12
+ @series[:values] = self.class.normalize(@series[:values])
13
+ end
14
+
15
+ class Aggregator
16
+ def initialize(series)
17
+ @series = series
18
+ end
19
+ end
20
+
21
+ def aggregate
22
+ @aggregate ||= Aggregator.new(self)
23
+ end
24
+
25
+ def self.register_aggregator(name, klass)
26
+ Aggregator.define_method(name) do |params|
27
+ klass.new.aggregate(series: @series.series, **params)
28
+ end
29
+ end
30
+
31
+ class Formatter
32
+ def initialize(series)
33
+ @series = series
34
+ end
35
+ end
36
+
37
+ def format
38
+ @format ||= Formatter.new(self)
39
+ end
40
+
41
+ def self.register_formatter(name, klass)
42
+ Formatter.define_method(name) do |params, &block|
43
+ klass.new.format(series: @series.series, **params, &block)
44
+ end
45
+ end
46
+
47
+ class Transponder
48
+ def initialize(series)
49
+ @series = series
50
+ end
51
+ end
52
+
53
+ def transpond
54
+ @transpond ||= Transponder.new(self)
55
+ end
56
+
57
+ def self.register_transponder(name, klass)
58
+ Transponder.define_method(name) do |params|
59
+ @series.series = klass.new.transpond(series: @series.series, **params)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Transponder
6
+ class Average
7
+ include Trifle::Stats::Mixins::Packer
8
+ Trifle::Stats::Series.register_transponder(:average, self)
9
+
10
+ def transpond(series:, path:, key: 'average', sum: 'sum', count: 'count') # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
11
+ keys = path.to_s.split('.')
12
+ sum = sum.to_s.split('.')
13
+ count = count.to_s.split('.')
14
+ key = path.to_s.empty? ? key : [path, key].join('.')
15
+ series[:values] = series[:values].map do |data|
16
+ dsum = data.dig(*keys, *sum)
17
+ dcount = data.dig(*keys, *count)
18
+ next data unless dsum && dcount
19
+
20
+ dres = (dsum / dcount)
21
+ signal = {
22
+ key => dres.nan? ? BigDecimal(0) : dres
23
+ }
24
+ self.class.deep_merge(data, self.class.unpack(hash: signal))
25
+ end
26
+ series
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Transponder
6
+ class Ratio
7
+ include Trifle::Stats::Mixins::Packer
8
+ Trifle::Stats::Series.register_transponder(:ratio, self)
9
+
10
+ def transpond(series:, path:, key: 'ratio', sample: 'sample', total: 'total') # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
11
+ keys = path.to_s.split('.')
12
+ sample = sample.to_s.split('.')
13
+ total = total.to_s.split('.')
14
+ key = path.to_s.empty? ? key : [path, key].join('.')
15
+ series[:values] = series[:values].map do |data|
16
+ dsample = data.dig(*keys, *sample)
17
+ dtotal = data.dig(*keys, *total)
18
+ next data unless dsample && dtotal
19
+
20
+ dres = (dsample / dtotal) * 100
21
+ signal = {
22
+ key => dres.nan? ? BigDecimal(0) : dres
23
+ }
24
+ self.class.deep_merge(data, self.class.unpack(hash: signal))
25
+ end
26
+ series
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Transponder
6
+ class StandardDeviation
7
+ include Trifle::Stats::Mixins::Packer
8
+ Trifle::Stats::Series.register_transponder(:standard_deviation, self)
9
+
10
+ def transpond(series:, path:, key: 'sd', sum: 'sum', count: 'count', square: 'square') # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
11
+ keys = path.to_s.split('.')
12
+ sum = sum.to_s.split('.')
13
+ count = count.to_s.split('.')
14
+ square = square.to_s.split('.')
15
+ key = path.to_s.empty? ? key : [path, key].join('.')
16
+ series[:values] = series[:values].map do |data|
17
+ dcount = data.dig(*keys, *count)
18
+ dsquare = data.dig(*keys, *square)
19
+ dsum = data.dig(*keys, *sum)
20
+ next data unless dcount && dsquare && dsum
21
+
22
+ dres = Math.sqrt(
23
+ (dcount * dsquare - dsum * dsum) / (dcount * (dcount - 1)) # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
24
+ )
25
+ signal = {
26
+ key => dres.nan? ? BigDecimal(0) : dres
27
+ }
28
+ self.class.deep_merge(data, self.class.unpack(hash: signal))
29
+ end
30
+ series
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Trifle
4
4
  module Stats
5
- VERSION = '1.3.0'
5
+ VERSION = '1.6.0'
6
6
  end
7
7
  end
data/lib/trifle/stats.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'trifle/stats/mixins/packer'
4
+ require 'trifle/stats/nocturnal'
5
+ require 'trifle/stats/series'
6
+ require 'trifle/stats/aggregator/avg'
7
+ require 'trifle/stats/aggregator/max'
8
+ require 'trifle/stats/aggregator/min'
9
+ require 'trifle/stats/aggregator/sum'
3
10
  require 'trifle/stats/designator/custom'
4
11
  require 'trifle/stats/designator/geometric'
5
12
  require 'trifle/stats/designator/linear'
@@ -8,13 +15,18 @@ require 'trifle/stats/driver/postgres'
8
15
  require 'trifle/stats/driver/process'
9
16
  require 'trifle/stats/driver/redis'
10
17
  require 'trifle/stats/driver/sqlite'
11
- require 'trifle/stats/mixins/packer'
12
- require 'trifle/stats/nocturnal'
18
+ require 'trifle/stats/formatter/category'
19
+ require 'trifle/stats/formatter/timeline'
13
20
  require 'trifle/stats/configuration'
14
21
  require 'trifle/stats/operations/timeseries/classify'
15
22
  require 'trifle/stats/operations/timeseries/increment'
16
23
  require 'trifle/stats/operations/timeseries/set'
17
24
  require 'trifle/stats/operations/timeseries/values'
25
+ require 'trifle/stats/operations/status/beam'
26
+ require 'trifle/stats/operations/status/scan'
27
+ require 'trifle/stats/transponder/average'
28
+ require 'trifle/stats/transponder/ratio'
29
+ require 'trifle/stats/transponder/standard_deviation'
18
30
  require 'trifle/stats/version'
19
31
 
20
32
  module Trifle
@@ -59,12 +71,33 @@ module Trifle
59
71
  ).perform
60
72
  end
61
73
 
62
- def self.values(key:, from:, to:, range:, config: nil)
74
+ def self.values(key:, from:, to:, range:, skip_blanks: false, config: nil) # rubocop:disable Metrics/ParameterLists
63
75
  Trifle::Stats::Operations::Timeseries::Values.new(
64
76
  key: key,
65
77
  from: from,
66
78
  to: to,
67
79
  range: range,
80
+ skip_blanks: skip_blanks,
81
+ config: config
82
+ ).perform
83
+ end
84
+
85
+ def self.series(**params)
86
+ Trifle::Stats::Series.new(values(**params))
87
+ end
88
+
89
+ def self.beam(key:, at:, values:, config: nil)
90
+ Trifle::Stats::Operations::Status::Beam.new(
91
+ key: key,
92
+ at: at,
93
+ values: values,
94
+ config: config
95
+ ).perform
96
+ end
97
+
98
+ def self.scan(key:, config: nil)
99
+ Trifle::Stats::Operations::Status::Scan.new(
100
+ key: key,
68
101
  config: config
69
102
  ).perform
70
103
  end
data/trifle-stats.gemspec CHANGED
@@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.metadata['homepage_uri'] = spec.homepage
20
20
  spec.metadata['source_code_uri'] = 'https://github.com/trifle-io/trifle-stats'
21
+ spec.metadata['changelog_uri'] = 'https://trifle.io/trifle-stats/changelog'
21
22
 
22
23
  # Specify which files should be added to the gem when it is released.
23
24
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.