trifle-stats 2.4.1 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 574331154d00b65ea5a994f7c7fbc754a1225c39617294acbeed3886c88d5b98
4
- data.tar.gz: d4cca3dd912c0642594805395f727fcce350cee4cd6b388ddf09fb3032895317
3
+ metadata.gz: 053aa86693f158ba5a395ab80396f0b3274bff69481ec80dd4335e782d000bfe
4
+ data.tar.gz: 86be7c68e870d4fe38eac8dcae73634d8237a5334c8071c610932b22b57c735d
5
5
  SHA512:
6
- metadata.gz: 423c591af0e2f61178be6553afef6ec4a52ab96268f30b82720e8bf591d079ece3b5b4753766f1ebeecffe442b92162ea89619a4aa7fa58298aee62b226f1cba
7
- data.tar.gz: a1fdba88136d21091e95eb59b1b87fab7e917626e40860d5e7128f7771c0428547e2070ba98ae8a9cf5437ee5d61c7671745336226fc25c46492fd6aee1756f1
6
+ metadata.gz: cfc8e1a9b317808a37acc6799d87a99d856fe4e4f4b792ac223d96f4ac500b2e6c77e25a5e99e1861047524984cf7ce2bd691a6d930404384b973a173ed49e99
7
+ data.tar.gz: 383c36c748fa4266b2d741054f1f22a35baff5806ccb5ab169d3a0cf49c9fabad2fae97e64acb86fe2fbd534600acad0fe5ee6edac7a01faea6340c5b1eb1c13
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- trifle-stats (2.4.1)
4
+ trifle-stats (2.6.0)
5
5
  tzinfo (~> 2.0)
6
6
 
7
7
  GEM
@@ -11,7 +11,7 @@ GEM
11
11
  bigdecimal (4.0.1)
12
12
  bson (4.12.1)
13
13
  byebug (11.1.3)
14
- concurrent-ruby (1.3.6)
14
+ concurrent-ruby (1.3.8)
15
15
  diff-lcs (1.4.4)
16
16
  dotenv (2.7.6)
17
17
  mini_portile2 (2.8.9)
data/README.md CHANGED
@@ -57,23 +57,42 @@ Trifle::Stats.values(
57
57
  #=> { at: [Mon, Tue, Wed, ...], values: [{ "count" => 12, "revenue" => 598_80, ... }, ...] }
58
58
  ```
59
59
 
60
+ ### 5. Process with Series
61
+
62
+ ```ruby
63
+ series = Trifle::Stats.series(
64
+ key: 'orders',
65
+ from: 1.week.ago,
66
+ to: Time.now,
67
+ granularity: :day
68
+ )
69
+
70
+ series.transpond.expression(
71
+ paths: ['revenue', 'count'],
72
+ expression: 'a / b',
73
+ response: 'avg_order'
74
+ )
75
+
76
+ series.aggregate.sum(path: 'count')
77
+ ```
78
+
60
79
  ## Drivers
61
80
 
62
81
  | Driver | Backend | Best for |
63
82
  |--------|---------|----------|
83
+ | **API** | Trifle Cloud Projects | Hosted metrics without your own database |
64
84
  | **Postgres** | JSONB upsert | Most production apps |
65
85
  | **Redis** | Hash increment | High-throughput counters |
66
86
  | **MongoDB** | Document upsert | Document-oriented stacks |
67
87
  | **MySQL** | JSON column | MySQL shops |
68
88
  | **SQLite** | JSON1 extension | Single-server apps, dev/test |
69
89
  | **Process** | In-memory | Testing |
70
- | **Dummy** | No-op | Disabled analytics |
71
90
 
72
91
  ## Features
73
92
 
74
93
  - **Dynamic time granularities.** Use any interval like `1m`, `10m`, `1h`, `6h`, `1d`, `1w`, `1mo`, `1q`, `1y`.
75
94
  - **Nested value hierarchies.** Track dimensional breakdowns in a single call.
76
- - **Series operations.** Aggregators (sum, avg, min, max), transponders, formatters.
95
+ - **Series operations.** Aggregators, the expression transponder, and formatters.
77
96
  - **Buffered writes.** Queue metrics in-memory before flushing to reduce write load.
78
97
  - **Driver flexibility.** Switch backends without changing application code.
79
98
 
@@ -42,6 +42,7 @@ module Trifle
42
42
 
43
43
  def storage
44
44
  return driver unless buffer_enabled
45
+ return driver if driver.respond_to?(:bypass_buffer?) && driver.bypass_buffer?
45
46
 
46
47
  @storage ||= Trifle::Stats::Buffer.new(
47
48
  driver: driver,
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'stringio'
6
+ require 'time'
7
+ require 'uri'
8
+ require 'zlib'
9
+ require_relative '../version'
10
+
11
+ module Trifle
12
+ module Stats
13
+ module Driver
14
+ class Api
15
+ ENDPOINT = URI('https://app.trifle.io/api/v1/metrics')
16
+ TIMEOUT = 10
17
+ ERROR_BODY_LIMIT = 1024
18
+
19
+ class Error < Trifle::Stats::Error
20
+ attr_reader :status, :response_body, :retry_after, :delivery_unknown
21
+
22
+ def initialize(message, status: nil, response_body: nil, retry_after: nil, delivery_unknown: false)
23
+ super(message)
24
+ @status = status
25
+ @response_body = response_body
26
+ @retry_after = retry_after
27
+ @delivery_unknown = delivery_unknown
28
+ end
29
+ end
30
+
31
+ class NetHttpTransport
32
+ def call(uri:, request:, timeout:)
33
+ Net::HTTP.start(
34
+ uri.hostname,
35
+ uri.port,
36
+ use_ssl: uri.scheme == 'https',
37
+ open_timeout: timeout,
38
+ read_timeout: timeout
39
+ ) { |http| http.request(request) }
40
+ end
41
+ end
42
+
43
+ attr_reader :token, :project_id
44
+
45
+ def initialize(token:, project_id:, transport: NetHttpTransport.new)
46
+ raise ArgumentError, 'token must not be empty' if token.to_s.strip.empty?
47
+ raise ArgumentError, 'project_id must not be empty' if project_id.to_s.strip.empty?
48
+
49
+ @token = token.to_s
50
+ @project_id = project_id.to_s
51
+ @transport = transport
52
+ end
53
+
54
+ def description
55
+ self.class.name
56
+ end
57
+
58
+ def bypass_buffer?
59
+ true
60
+ end
61
+
62
+ def direct_write(operation:, key:, at:, values:, untracked: false)
63
+ body = gzip(JSON.generate(
64
+ operation: operation.to_s,
65
+ key: key,
66
+ at: at.to_time.iso8601(6),
67
+ values: values,
68
+ untracked: untracked
69
+ ))
70
+ request = Net::HTTP::Post.new(ENDPOINT)
71
+ request['Authorization'] = "Bearer #{token}"
72
+ request['X-Trifle-Source-Id'] = project_id
73
+ request['Content-Type'] = 'application/json'
74
+ request['Accept'] = 'application/json'
75
+ request['Content-Encoding'] = 'gzip'
76
+ request['User-Agent'] = "trifle-stats-ruby/#{Trifle::Stats::VERSION}"
77
+ request.body = body
78
+
79
+ response = @transport.call(uri: ENDPOINT, request: request, timeout: TIMEOUT)
80
+ return true if response.code.to_i.between?(200, 299)
81
+
82
+ response_body = response.body.to_s.byteslice(0, ERROR_BODY_LIMIT)
83
+ raise Error.new(
84
+ "Trifle API returned HTTP #{response.code}",
85
+ status: response.code.to_i,
86
+ response_body: response_body,
87
+ retry_after: response['Retry-After']
88
+ )
89
+ rescue Error
90
+ raise
91
+ rescue StandardError => e
92
+ raise Error.new("Trifle API request failed: #{e.message}", delivery_unknown: true), cause: e
93
+ end
94
+
95
+ def inc(*)
96
+ unsupported!(:track)
97
+ end
98
+
99
+ def set(*)
100
+ unsupported!(:assert)
101
+ end
102
+
103
+ def get(*)
104
+ unsupported!(:values)
105
+ end
106
+
107
+ def ping(*)
108
+ unsupported!(:beam)
109
+ end
110
+
111
+ def scan(*)
112
+ unsupported!(:scan)
113
+ end
114
+
115
+ private
116
+
117
+ def gzip(value)
118
+ output = StringIO.new
119
+ Zlib::GzipWriter.wrap(output) { |writer| writer.write(value) }
120
+ output.string
121
+ end
122
+
123
+ def unsupported!(operation)
124
+ message = "#{operation} must be called through Trifle::Stats; " \
125
+ 'API driver does not support direct storage operations'
126
+ raise Error, message
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -95,10 +95,9 @@ module Trifle
95
95
  end
96
96
 
97
97
  def get(keys:)
98
- keys.map do |key|
99
- identifier = identifier_for(key)
100
- self.class.unpack(hash: fetch_packed_data(identifier))
101
- end
98
+ identifiers = keys.map { |key| identifier_for(key) }
99
+ data = get_all(identifiers: identifiers)
100
+ identifiers.map { |identifier| self.class.unpack(hash: data.fetch(lookup_key_for(identifier), {})) }
102
101
  end
103
102
 
104
103
  def ping(key:, values:)
@@ -157,23 +156,42 @@ module Trifle
157
156
  execute_prepared(connection, query, args)
158
157
  end
159
158
 
160
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
161
- def fetch_packed_data(identifier)
162
- conditions = identifier.keys.map { |column| "#{self.class.quote_identifier(column)} = ?" }.join(' AND ')
159
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
160
+ def get_all(identifiers:)
161
+ columns = identifiers.first.keys
162
+ # Select `at` as a plain string to avoid client time zone casting
163
+ columns_sql = columns.map do |column|
164
+ if column == :at
165
+ "DATE_FORMAT(`at`, '%Y-%m-%d %H:%i:%s.%f') AS at"
166
+ else
167
+ self.class.quote_identifier(column)
168
+ end
169
+ end.join(', ')
170
+ conditions = identifiers.map do |identifier|
171
+ "(#{identifier.keys.map { |column| "#{self.class.quote_identifier(column)} = ?" }.join(' AND ')})"
172
+ end.join(' OR ')
173
+
163
174
  query = <<~SQL
164
- SELECT CAST(`data` AS CHAR) AS data
175
+ SELECT #{columns_sql}, CAST(`data` AS CHAR) AS data
165
176
  FROM #{self.class.quote_identifier(table_name)}
166
177
  WHERE #{conditions}
167
- LIMIT 1
168
178
  SQL
169
- packed_data = execute_prepared(client, query, query_values(identifier)).first&.fetch('data', nil)
170
- return {} if packed_data.nil? || packed_data.empty?
179
+ args = identifiers.flat_map { |identifier| query_values(identifier) }
171
180
 
172
- JSON.parse(packed_data)
173
- rescue JSON::ParserError
174
- {}
181
+ execute_prepared(client, query, args).each_with_object({}) do |row, o|
182
+ identifier = columns.to_h { |column| [column, row[column.to_s]] }
183
+ o[lookup_key_for(identifier)] = JSON.parse(row['data'])
184
+ rescue JSON::ParserError
185
+ nil
186
+ end
187
+ end
188
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
189
+
190
+ def lookup_key_for(identifier)
191
+ identifier.map do |_column, value|
192
+ value.is_a?(Time) || value.is_a?(DateTime) ? format_time_value(value) : value.to_s
193
+ end.join('|')
175
194
  end
176
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
177
195
 
178
196
  def inc_query(identifier:, data:)
179
197
  upsert_query(
@@ -61,22 +61,26 @@ module Trifle
61
61
  client.transaction do |c|
62
62
  keys.map do |key|
63
63
  identifier = identifier_for(key)
64
- c.exec(inc_query(identifier: identifier, data: data))
64
+ query, params = inc_query(identifier: identifier, data: data)
65
+ c.exec_params(query, params)
65
66
  track_system_data(c, key, count, tracking_key)
66
67
  end
67
68
  end
68
69
  end
69
70
 
70
- def inc_query(identifier:, data:)
71
+ def inc_query(identifier:, data:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
71
72
  columns = identifier.keys.join(', ')
72
- values = identifier.values.map { |v| format_value(v) }.join(', ')
73
- conflict_columns = identifier.keys.join(', ')
73
+ placeholders = identifier.each_with_index.map { |_, i| "$#{i + 1}" }.join(', ')
74
+ expression = data.inject("to_jsonb(#{table_name}.data)") do |o, (k, v)|
75
+ path = escape_string(k.to_s)
76
+ "jsonb_set(#{o}, '{#{path}}', (COALESCE(#{table_name}.data->>'#{path}', '0')::numeric + #{numeric_value(k, v)})::text::jsonb)" # rubocop:disable Layout/LineLength
77
+ end
74
78
 
75
- <<-SQL
76
- INSERT INTO #{table_name} (#{columns}, data) VALUES (#{values}, '#{data.to_json}')
77
- ON CONFLICT (#{conflict_columns}) DO UPDATE SET data =
78
- #{data.inject("to_jsonb(#{table_name}.data)") { |o, (k, v)| "jsonb_set(#{o}, '{#{k}}', (COALESCE(#{table_name}.data->>'#{k}', '0')::int + #{v})::text::jsonb)" }};
79
+ query = <<-SQL
80
+ INSERT INTO #{table_name} (#{columns}, data) VALUES (#{placeholders}, $#{identifier.size + 1})
81
+ ON CONFLICT (#{columns}) DO UPDATE SET data = #{expression};
79
82
  SQL
83
+ [query, query_params(identifier) + [data.to_json]]
80
84
  end
81
85
 
82
86
  def set(keys:, values:, count: 1, tracking_key: nil)
@@ -84,7 +88,8 @@ module Trifle
84
88
  client.transaction do |c|
85
89
  keys.map do |key|
86
90
  identifier = identifier_for(key)
87
- c.exec(set_query(identifier: identifier, data: data))
91
+ query, params = set_query(identifier: identifier, data: data)
92
+ c.exec_params(query, params)
88
93
  track_system_data(c, key, count, tracking_key)
89
94
  end
90
95
  end
@@ -94,21 +99,24 @@ module Trifle
94
99
  return unless @system_tracking
95
100
 
96
101
  system_data = system_data_for(key: key, count: count, tracking_key: tracking_key)
97
- connection.exec(
98
- inc_query(identifier: system_identifier_for(key: key), data: system_data)
99
- )
102
+ query, params = inc_query(identifier: system_identifier_for(key: key), data: system_data)
103
+ connection.exec_params(query, params)
100
104
  end
101
105
 
102
- def set_query(identifier:, data:)
106
+ def set_query(identifier:, data:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
103
107
  columns = identifier.keys.join(', ')
104
- values = identifier.values.map { |v| format_value(v) }.join(', ')
105
- conflict_columns = identifier.keys.join(', ')
108
+ placeholders = identifier.each_with_index.map { |_, i| "$#{i + 1}" }.join(', ')
109
+ params = query_params(identifier) + [data.to_json]
110
+ expression = data.inject("to_jsonb(#{table_name}.data)") do |o, (k, v)|
111
+ params << v.to_json
112
+ "jsonb_set(#{o}, '{#{escape_string(k.to_s)}}', $#{params.size}::jsonb)"
113
+ end
106
114
 
107
- <<-SQL
108
- INSERT INTO #{table_name} (#{columns}, data) VALUES (#{values}, '#{data.to_json}')
109
- ON CONFLICT (#{conflict_columns}) DO UPDATE SET data =
110
- #{data.inject("to_jsonb(#{table_name}.data)") { |o, (k, v)| "jsonb_set(#{o}, '{#{k}}', '#{v.to_json}'::jsonb)" }}
115
+ query = <<-SQL
116
+ INSERT INTO #{table_name} (#{columns}, data) VALUES (#{placeholders}, $#{identifier.size + 1})
117
+ ON CONFLICT (#{columns}) DO UPDATE SET data = #{expression}
111
118
  SQL
119
+ [query, params]
112
120
  end
113
121
 
114
122
  def get(keys:)
@@ -118,7 +126,8 @@ module Trifle
118
126
  end
119
127
 
120
128
  def get_all(identifiers:) # rubocop:disable Metrics/AbcSize
121
- results = client.exec_params(get_query(identifiers: identifiers)).to_a
129
+ query, params = get_query(identifiers: identifiers)
130
+ results = client.exec_params(query, params).to_a
122
131
  sample = identifiers.first
123
132
 
124
133
  results.each_with_object(Hash.new({})) do |r, o|
@@ -130,37 +139,44 @@ module Trifle
130
139
  end
131
140
  end
132
141
 
133
- def get_query(identifiers:)
142
+ def get_query(identifiers:) # rubocop:disable Metrics/MethodLength
143
+ params = []
134
144
  conditions = identifiers.map do |identifier|
135
- identifier.map { |k, v| "#{k} = #{format_value(v)}" }.join(' AND ')
145
+ identifier.map do |k, v|
146
+ params << query_param(v)
147
+ "#{k} = $#{params.size}"
148
+ end.join(' AND ')
136
149
  end.join(' OR ')
137
150
 
138
- <<-SQL
151
+ query = <<-SQL
139
152
  SELECT * FROM #{table_name} WHERE #{conditions};
140
153
  SQL
154
+ [query, params]
141
155
  end
142
156
 
143
157
  def ping(key:, values:)
144
158
  return [] if @joined_identifier
145
159
 
146
160
  data = self.class.pack(hash: { data: values, at: key.at })
147
- operation = ping_query(key: key.key, at: key.at, data: data)
161
+ query, params = ping_query(key: key.key, at: key.at, data: data)
148
162
  client.transaction do |c|
149
- c.exec(operation)
163
+ c.exec_params(query, params)
150
164
  end
151
165
  end
152
166
 
153
167
  def ping_query(key:, at:, data:)
154
- <<-SQL
155
- INSERT INTO #{ping_table_name} (key, at, data) VALUES ('#{key}', '#{at.iso8601}', '#{data.to_json}')
156
- ON CONFLICT (key) DO UPDATE SET at = '#{at.iso8601}', data = '#{data.to_json}'::jsonb;
168
+ query = <<-SQL
169
+ INSERT INTO #{ping_table_name} (key, at, data) VALUES ($1, $2, $3)
170
+ ON CONFLICT (key) DO UPDATE SET at = $2, data = $3::jsonb;
157
171
  SQL
172
+ [query, [key.to_s, at.iso8601, data.to_json]]
158
173
  end
159
174
 
160
175
  def scan(key:)
161
176
  return [] if @joined_identifier
162
177
 
163
- result = client.exec_params(scan_query(key: key.key)).to_a.first
178
+ query, params = scan_query(key: key.key)
179
+ result = client.exec_params(query, params).to_a.first
164
180
  return [] if result.nil?
165
181
 
166
182
  [Time.parse(result['at']), self.class.unpack(hash: JSON.parse(result['data']))]
@@ -169,9 +185,10 @@ module Trifle
169
185
  end
170
186
 
171
187
  def scan_query(key:)
172
- <<-SQL
173
- SELECT at, data FROM #{ping_table_name} WHERE key = '#{key}' ORDER BY at DESC LIMIT 1;
188
+ query = <<-SQL
189
+ SELECT at, data FROM #{ping_table_name} WHERE key = $1 ORDER BY at DESC LIMIT 1;
174
190
  SQL
191
+ [query, [key.to_s]]
175
192
  end
176
193
 
177
194
  def self.normalize_joined_identifier(value)
@@ -185,19 +202,31 @@ module Trifle
185
202
 
186
203
  private
187
204
 
188
- def format_value(value)
205
+ def query_params(identifier)
206
+ identifier.values.map { |value| query_param(value) }
207
+ end
208
+
209
+ def query_param(value)
189
210
  case value
190
- when String
191
- "'#{value}'"
192
211
  when Time
193
- "'#{value.utc.strftime('%Y-%m-%d %H:%M:%S+00')}'"
212
+ value.utc.strftime('%Y-%m-%d %H:%M:%S+00')
194
213
  when Integer, Float
195
- value.to_s
214
+ value
196
215
  else
197
- "'#{value}'"
216
+ value.to_s
198
217
  end
199
218
  end
200
219
 
220
+ def numeric_value(key, value)
221
+ return value.to_s if value.is_a?(Numeric)
222
+
223
+ raise ArgumentError, "increment requires numeric value for key #{key.inspect}"
224
+ end
225
+
226
+ def escape_string(value)
227
+ value.gsub("'", "''")
228
+ end
229
+
201
230
  def build_map_key(data)
202
231
  return data[:key] if @joined_identifier == :full
203
232
  return "#{data[:key]}::#{data[:at]}" if @joined_identifier == :partial
@@ -31,47 +31,43 @@ module Trifle
31
31
  self.class.pack(hash: { count: count, keys: { tracking_key => count } })
32
32
  end
33
33
 
34
- def inc(keys:, values:, count: 1, tracking_key: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
35
- keys.map do |key|
36
- key.prefix = prefix
37
- pkey = key.join(separator)
38
-
39
- self.class.pack(hash: values).each do |k, c|
40
- client.hincrby(pkey, k, c)
41
- end
42
- next unless @system_tracking
43
-
44
- skey = system_join_for(key: key)
45
- system_data_for(key: key, count: count, tracking_key: tracking_key).each do |k, c|
46
- client.hincrby(skey, k, c)
34
+ def inc(keys:, values:, count: 1, tracking_key: nil) # rubocop:disable Metrics/MethodLength
35
+ packed = self.class.pack(hash: values)
36
+ client.pipelined do |pipeline|
37
+ keys.each do |key|
38
+ key.prefix = prefix
39
+ pkey = key.join(separator)
40
+
41
+ packed.each do |k, c|
42
+ pipeline.hincrby(pkey, k, c)
43
+ end
44
+ track_system_data(pipeline, key, count, tracking_key)
47
45
  end
48
46
  end
49
47
  end
50
48
 
51
49
  def set(keys:, values:, count: 1, tracking_key: nil)
52
- keys.map do |key|
53
- key.prefix = prefix
54
- pkey = key.join(separator)
55
-
56
- client.hmset(pkey, *self.class.pack(hash: values))
57
- next unless @system_tracking
58
-
59
- skey = system_join_for(key: key)
60
- system_data_for(key: key, count: count, tracking_key: tracking_key).each do |k, c|
61
- client.hincrby(skey, k, c)
50
+ packed = self.class.pack(hash: values)
51
+ client.pipelined do |pipeline|
52
+ keys.each do |key|
53
+ key.prefix = prefix
54
+ pkey = key.join(separator)
55
+
56
+ pipeline.hmset(pkey, *packed)
57
+ track_system_data(pipeline, key, count, tracking_key)
62
58
  end
63
59
  end
64
60
  end
65
61
 
66
62
  def get(keys:)
67
- keys.map do |key|
68
- key.prefix = prefix
69
- pkey = key.join(separator)
70
-
71
- self.class.unpack(
72
- hash: client.hgetall(pkey)
73
- )
63
+ results = client.pipelined do |pipeline|
64
+ keys.each do |key|
65
+ key.prefix = prefix
66
+ pipeline.hgetall(key.join(separator))
67
+ end
74
68
  end
69
+
70
+ results.map { |hash| self.class.unpack(hash: hash) }
75
71
  end
76
72
 
77
73
  def ping(*)
@@ -81,6 +77,17 @@ module Trifle
81
77
  def scan(*)
82
78
  []
83
79
  end
80
+
81
+ private
82
+
83
+ def track_system_data(pipeline, key, count, tracking_key)
84
+ return unless @system_tracking
85
+
86
+ skey = system_join_for(key: key)
87
+ system_data_for(key: key, count: count, tracking_key: tracking_key).each do |k, c|
88
+ pipeline.hincrby(skey, k, c)
89
+ end
90
+ end
84
91
  end
85
92
  end
86
93
  end