Skip to content

Commit 12c7494

Browse files
committed
Introduce AppSizeMetricsHelper
To help build a payload for the grouped-metrics API
1 parent 329effd commit 12c7494

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
require 'json'
2+
require 'net/http'
3+
require 'zlib'
4+
5+
module Fastlane
6+
module WPMRT
7+
# A helper class to build an App Size Metrics payload and send it to a server (or write it to disk)
8+
#
9+
# The payload generated (and sent) by this helper conforms to the API for grouped metrics described in
10+
# https://github.com/Automattic/apps-metrics
11+
#
12+
class AppSizeMetricsHelper
13+
# @param [Hash] group_meta Metadata common to all the metrics. Can be any arbitrary set of key/value pairs.
14+
#
15+
def initialize(group_meta = {})
16+
self.meta = group_meta
17+
@metrics = []
18+
end
19+
20+
# Sets the metadata common to the whole group of metrics in the payload being built by this helper instance
21+
#
22+
# @param [Hash] hash The metadata common to all the metrics of the payload built by that helper instance. Can be any arbitrary set of key/value pairs
23+
#
24+
def meta=(hash)
25+
@meta = (hash.compact || {}).map { |key, value| { name: key.to_s, value: value } }
26+
end
27+
28+
# Adds a single metric to the group of metrics
29+
#
30+
# @param [String] name The metric name
31+
# @param [Integer] value The metric value
32+
# @param [Hash] meta The arbitrary dictionary of metadata to associate to that metric entry
33+
#
34+
def add_metric(name:, value:, meta: nil)
35+
metric = { name: name, value: value }
36+
meta = (meta || {}).compact # Remove nil values if any
37+
metric[:meta] = meta.map { |meta_key, meta_value| { name: meta_key.to_s, value: meta_value } } unless meta.empty?
38+
@metrics.append(metric)
39+
end
40+
41+
def to_h
42+
{
43+
meta: @meta,
44+
metrics: @metrics
45+
}
46+
end
47+
48+
# Send the metrics to the given App Metrics endpoint.
49+
#
50+
# Must conform to the API described in https://github.com/Automattic/apps-metrics/wiki/Queue-Group-of-Metrics
51+
#
52+
# @param [String,URI] base_url The base URL of the App Metrics service
53+
# @param [String] api_token The API bearer token to use to register the metric.
54+
# @return [Integer] the HTTP response code
55+
#
56+
def send_metrics(base_url:, api_token:, use_gzip: true)
57+
uri = URI(base_url)
58+
json_payload = use_gzip ? Zlib.gzip(to_h.to_json) : to_h.to_json
59+
60+
# Allow using a `file:` URI for debugging
61+
if uri.is_a?(URI::File)
62+
UI.message("Writing metrics payload to file #{uri.path} (instead of sending it to a server)")
63+
File.write(uri.path, json_payload)
64+
return 201 # To make it easy at call site to check for pseudo-status code 200 even in non-HTTP cases
65+
end
66+
67+
UI.message("Sending metrics to #{uri}...")
68+
headers = {
69+
Authorization: "Bearer #{api_token}",
70+
Accept: 'application/json',
71+
'Content-Type': 'application/json'
72+
}
73+
headers[:'Content-Encoding'] = 'gzip' if use_gzip
74+
75+
request = Net::HTTP::Post.new(uri, headers)
76+
request.body = json_payload
77+
78+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
79+
http.request(request)
80+
end
81+
82+
if response.is_a?(Net::HTTPSuccess)
83+
UI.message("Metrics sent. (#{response.code} #{response.message})")
84+
else
85+
UI.error("Metrics failed to send. Received: #{response.code} #{response.message}")
86+
UI.message("Request was #{request.method} to #{request.uri}")
87+
UI.message("Request headers were: #{headers}")
88+
UI.message("Request body was #{request.body.length} bytes")
89+
UI.message("Response was #{response.body}")
90+
end
91+
response.code.to_i
92+
end
93+
end
94+
end
95+
end

spec/app_size_metrics_helper_spec.rb

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
require_relative './spec_helper'
2+
3+
describe Fastlane::WPMRT::AppSizeMetricsHelper do
4+
describe '#to_h' do
5+
it 'generates the right payload from raw data' do
6+
metrics_helper = described_class.new({
7+
'Group Metadata 1': 'Group Value 1',
8+
'Group Metadata 2': 'Group Value 2'
9+
})
10+
metrics_helper.add_metric(name: 'Metric 1', value: 12_345, meta: { m1a: 'Metric 1 Metadata A' })
11+
metrics_helper.add_metric(name: 'Metric 2', value: 67_890)
12+
metrics_helper.add_metric(name: 'Metric 3', value: 13_579, meta: { m3a: 'Metric 3 Metadata A', m3b: 'Metric 3 Metadata B' })
13+
14+
expected_hash = {
15+
meta: [
16+
{ name: 'Group Metadata 1', value: 'Group Value 1' },
17+
{ name: 'Group Metadata 2', value: 'Group Value 2' },
18+
],
19+
metrics: [
20+
{ name: 'Metric 1', value: 12_345, meta: [{ name: 'm1a', value: 'Metric 1 Metadata A' }] },
21+
{ name: 'Metric 2', value: 67_890 },
22+
{ name: 'Metric 3', value: 13_579, meta: [{ name: 'm3a', value: 'Metric 3 Metadata A' }, { name: 'm3b', value: 'Metric 3 Metadata B' }] },
23+
]
24+
}
25+
expect(metrics_helper.to_h).to eq(expected_hash)
26+
end
27+
28+
it 'removes `nil` values in metadata' do
29+
metrics_helper = described_class.new({
30+
'Group Metadata 1': 'Group Value 1',
31+
'Group Metadata 2': nil,
32+
'Group Metadata 3': 'Group Value 3'
33+
})
34+
metrics_helper.add_metric(name: 'Metric 1', value: 12_345, meta: { m1a: 'Metric 1 Metadata A', m1b: nil, m1c: 'Metric 1 Metadata C' })
35+
metrics_helper.add_metric(name: 'Metric 2', value: 67_890, meta: { m2a: nil })
36+
metrics_helper.add_metric(name: 'Metric 3', value: 13_579, meta: { m3a: 'Metric 3 Metadata A', m3b: 'Metric 3 Metadata B' })
37+
38+
expected_hash = {
39+
meta: [
40+
{ name: 'Group Metadata 1', value: 'Group Value 1' },
41+
{ name: 'Group Metadata 3', value: 'Group Value 3' },
42+
],
43+
metrics: [
44+
{ name: 'Metric 1', value: 12_345, meta: [{ name: 'm1a', value: 'Metric 1 Metadata A' }, { name: 'm1c', value: 'Metric 1 Metadata C' }] },
45+
{ name: 'Metric 2', value: 67_890 },
46+
{ name: 'Metric 3', value: 13_579, meta: [{ name: 'm3a', value: 'Metric 3 Metadata A' }, { name: 'm3b', value: 'Metric 3 Metadata B' }] },
47+
]
48+
}
49+
expect(metrics_helper.to_h).to eq(expected_hash)
50+
end
51+
end
52+
53+
describe '#send_metrics' do
54+
let(:metrics_helper) do
55+
metrics_helper = described_class.new({
56+
'Group Metadata 1': 'Group Value 1',
57+
'Group Metadata 2': 'Group Value 2'
58+
})
59+
metrics_helper.add_metric(name: 'Metric 1', value: 12_345, meta: { m1a: 'Metric 1 Metadata A' })
60+
metrics_helper.add_metric(name: 'Metric 2', value: 67_890)
61+
metrics_helper.add_metric(name: 'Metric 3', value: 13_579, meta: { m3a: 'Metric 3 Metadata A', m3b: 'Metric 3 Metadata B' })
62+
metrics_helper
63+
end
64+
let(:expected_data) do
65+
{
66+
meta: [
67+
{ name: 'Group Metadata 1', value: 'Group Value 1' },
68+
{ name: 'Group Metadata 2', value: 'Group Value 2' },
69+
],
70+
metrics: [
71+
{ name: 'Metric 1', value: 12_345, meta: [{ name: 'm1a', value: 'Metric 1 Metadata A' }] },
72+
{ name: 'Metric 2', value: 67_890 },
73+
{ name: 'Metric 3', value: 13_579, meta: [{ name: 'm3a', value: 'Metric 3 Metadata A' }, { name: 'm3b', value: 'Metric 3 Metadata B' }] },
74+
]
75+
}.to_json
76+
end
77+
78+
context 'when using file:// scheme for the URL' do
79+
it 'writes the payload uncompressed to a file when disabling gzip' do
80+
in_tmp_dir do |tmp_dir|
81+
output_file = File.join(tmp_dir, 'payload.json')
82+
base_url = File.join('file://localhost/', output_file)
83+
84+
code = metrics_helper.send_metrics(base_url: base_url, api_token: nil, use_gzip: false)
85+
86+
expect(code).to eq(201)
87+
expect(File).to exist(output_file)
88+
uncompressed_data = File.read(output_file)
89+
expect(uncompressed_data).to eq(expected_data)
90+
end
91+
end
92+
93+
it 'writes the payload compressed to a file when enabling gzip' do
94+
in_tmp_dir do |tmp_dir|
95+
output_file = File.join(tmp_dir, 'payload.json.gz')
96+
base_url = File.join('file://localhost/', output_file)
97+
98+
code = metrics_helper.send_metrics(base_url: base_url, api_token: nil, use_gzip: true)
99+
100+
expect(code).to eq(201)
101+
expect(File).to exist(output_file)
102+
uncompressed_data = Zlib::GzipReader.open(output_file, &:read)
103+
expect(uncompressed_data).to eq(expected_data)
104+
end
105+
end
106+
end
107+
108+
context 'when using non-file:// scheme for the URL' do
109+
let(:base_url) { 'https://fake-metrics-server/api/grouped-metrics' }
110+
let(:token) { 'fake#tokn' }
111+
112+
it 'sends the payload uncompressed to the server and with the right headers when disabling gzip' do
113+
expected_headers = {
114+
Authorization: "Bearer #{token}",
115+
Accept: 'application/json',
116+
'Content-Type': 'application/json'
117+
}
118+
last_received_body = nil
119+
stub = stub_request(:post, base_url).with(headers: expected_headers) do |req|
120+
last_received_body = req.body
121+
end.to_return(status: 201)
122+
123+
code = metrics_helper.send_metrics(base_url: base_url, api_token: token, use_gzip: false)
124+
125+
expect(code).to eq(201)
126+
expect(stub).to have_been_made.once
127+
expect(last_received_body).to eq(expected_data)
128+
end
129+
130+
it 'sends the payload compressed to the server and with the right headers when enabling gzip' do
131+
expected_headers = {
132+
Authorization: "Bearer #{token}",
133+
Accept: 'application/json',
134+
'Content-Type': 'application/json',
135+
'Content-Encoding': 'gzip'
136+
}
137+
last_received_body = nil
138+
stub = stub_request(:post, base_url).with(headers: expected_headers) do |req|
139+
last_received_body = req.body
140+
end.to_return(status: 201)
141+
142+
code = metrics_helper.send_metrics(base_url: base_url, api_token: token, use_gzip: true)
143+
144+
expect(code).to eq(201)
145+
expect(stub).to have_been_made.once
146+
expect do
147+
last_received_body = Zlib.gunzip(last_received_body)
148+
end.not_to raise_error
149+
expect(last_received_body).to eq(expected_data)
150+
end
151+
end
152+
end
153+
end

0 commit comments

Comments
 (0)