soracom_summary 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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e0fdd3af68a2563ceb97e1276db7a114c1d51c93
4
+ data.tar.gz: 1b70ced95d1f6adeee8dc4975af46f3f514d5fc2
5
+ SHA512:
6
+ metadata.gz: d3fe57db7e72cb1eb2bed0f1e4c324ef44534a35f762819492d6ed14537f4fe8a4598c0bdb71bb5814ed12ccd968f674c4944752d8b40ce69880b18ca58c8a7a
7
+ data.tar.gz: f8ec6b884d6305a7228544d67005b7fc17f4c854972f7f4e0f4b09ad5b26e7a9591d4f5b37bb6d27a624c9709c27d7407ed7aa2fe25b8ab89d69e62da0fdcc95
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+ .vscode/
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.3.3
7
+ before_install: gem install bundler -v 2.0.1
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at funahara@hioki.co.jp. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in soracom_summary.gemspec
4
+ gemspec
@@ -0,0 +1,42 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ soracom_summary (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ debase (0.2.4.1)
10
+ debase-ruby_core_source (>= 0.10.2)
11
+ debase-ruby_core_source (0.10.7)
12
+ diff-lcs (1.3)
13
+ rake (10.5.0)
14
+ rspec (3.5.0)
15
+ rspec-core (~> 3.5.0)
16
+ rspec-expectations (~> 3.5.0)
17
+ rspec-mocks (~> 3.5.0)
18
+ rspec-core (3.5.4)
19
+ rspec-support (~> 3.5.0)
20
+ rspec-expectations (3.5.0)
21
+ diff-lcs (>= 1.2.0, < 2.0)
22
+ rspec-support (~> 3.5.0)
23
+ rspec-mocks (3.5.0)
24
+ diff-lcs (>= 1.2.0, < 2.0)
25
+ rspec-support (~> 3.5.0)
26
+ rspec-support (3.5.0)
27
+ ruby-debug-ide (0.7.0)
28
+ rake (>= 0.8.1)
29
+
30
+ PLATFORMS
31
+ ruby
32
+
33
+ DEPENDENCIES
34
+ bundler (~> 2.0)
35
+ debase
36
+ rake (~> 10.0)
37
+ rspec (~> 3.0)
38
+ ruby-debug-ide
39
+ soracom_summary!
40
+
41
+ BUNDLED WITH
42
+ 2.0.1
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 Ippei Funahara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,86 @@
1
+ # SORACOM Summary
2
+ SORACOM Summary は、ソラコム株式会社の提供する回線サービスSORACOMの使用状況をWeb APIを用いてスクレイピングし、
3
+ 取得した使用状況をSORACOM Harvestにアップロードすることにより可視化するツールです。
4
+
5
+ ## インストール
6
+
7
+ ```
8
+ $ gem install soracom_summary
9
+ ```
10
+
11
+ ライブラリ及びsoracom_summaryコマンドがインストールされます。
12
+
13
+ ## 使用方法
14
+
15
+ ### ユーザー認証情報の設定
16
+ 環境変数にSAMユーザーの認証キーIDと認証キーシークレットを設定します。
17
+ SAMユーザーの説明はこちら
18
+ https://dev.soracom.io/jp/start/sam/
19
+
20
+ ```
21
+ $ export SORACOM_AUTH_KEY_ID='keyId-XXXX'
22
+ $ export SORACOM_AUTH_KEY='secret-XXXX'
23
+ ```
24
+
25
+ SAMユーザーには、以下の権限が必要です。上記ドキュメントを参照して、権限を設定します。
26
+ - Subscriber:listSubscribers
27
+ - Subscriber:listSessionEvents
28
+ - Log:getLogs
29
+ - Billing:exportBilling
30
+ - Stats:exportAirStats
31
+
32
+ ### デバイス認証情報の設定
33
+
34
+ 環境変数にHarvestにアップロードするためのSORACOM InventoryのデバイスIDとデバイスシークレットを設定します。
35
+ デバイスIDとデバイスシークレットについての説明はこちら
36
+ https://dev.soracom.io/jp/start/inventory_harvest_with_keys/
37
+
38
+ 集計情報用(必須)
39
+ ```
40
+ $ export SORACOM_SUMMARY_DEVICE_ID='d-XXXX'
41
+ $ export SORACOM_SUMMARY_DEVICE_SECRET='XXXX'
42
+ ```
43
+
44
+ セッション情報の分布用(オプション)
45
+ ```
46
+ $ export SORACOM_SESSION_DEVICE_ID='d-XXXX'
47
+ $ export SORACOM_SESSION_DEVICE_SECRET='XXXX'
48
+ ```
49
+
50
+ 請求情報の分布用(オプション)
51
+ ```
52
+ $ export SORACOM_BILLING_DEVICE_ID='d-XXXX'
53
+ $ export SORACOM_BILLING_DEVICE_SECRET='XXXX'
54
+ ```
55
+
56
+ 通信量の分布用(オプション)
57
+ ```
58
+ $ export SORACOM_TRAFFIC_DEVICE_ID='d-XXXX'
59
+ $ export SORACOM_TRAFFIC_DEVICE_SECRET='XXXX'
60
+ ```
61
+
62
+ ### 使用方法
63
+ 以下のコマンドを実行すると1日前の集計をします
64
+ ```
65
+ $ soracom_summary
66
+ ```
67
+
68
+ セッション情報を取得するには時間がかかるため、--sessionオプションで明示的に指定する必要があります
69
+ ```
70
+ $ soracom_summary --session
71
+ ```
72
+
73
+ 指定したタグごとにSIMの枚数を集計する場合、--tagオプションを使用します
74
+ ```
75
+ $ soracom_summary --tag environment
76
+ ```
77
+
78
+ 過去にさかのぼって集計する場合、--from、--toオプションを使用します
79
+ ```
80
+ $ soracom_summary --from 2019-11-22 --to 2019-11-24
81
+ ```
82
+
83
+ 実行後、データはSORACOM Harvestにアップロードされ、SORACOM Lagoonにて可視化することが出来ます。
84
+
85
+ Lagoonでの可視化は以下を参照して設定します。
86
+ https://dev.soracom.io/jp/start/lagoon-panel/
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "soracom_summary"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # coding: utf-8
3
+ require 'soracom_summary'
4
+
5
+ SoracomSummary::CLI.run(ARGV)
@@ -0,0 +1,8 @@
1
+ require "soracom_summary/version"
2
+ require "soracom_summary/const"
3
+ require "soracom_summary/cli"
4
+ require 'soracom_summary/api_client'
5
+ require 'soracom_summary/http_client'
6
+ require 'soracom_summary/subscriber'
7
+ require 'soracom_summary/billing'
8
+ require 'soracom_summary/traffic'
@@ -0,0 +1,218 @@
1
+ require 'time'
2
+ require 'csv'
3
+ require 'uri'
4
+ require 'logger'
5
+
6
+ module SoracomSummary
7
+ class ApiClient
8
+ BASE_URL = 'https://api.soracom.io/v1/'
9
+ def initialize(
10
+ auth_key_id: ENV['SORACOM_AUTH_KEY_ID'],
11
+ auth_key: ENV['SORACOM_AUTH_KEY']
12
+ )
13
+ @logger = Logger.new(STDERR)
14
+ @logger.level = ENV['SORACOM_SUMMARY_DEBUG'] ? Logger::DEBUG : Logger::WARN
15
+ @http_client = HttpClient.new(base_url: BASE_URL)
16
+ @token = authenticate(auth_key_id, auth_key)
17
+ end
18
+
19
+ def get_subscribers
20
+ @logger.debug('start get subscribers')
21
+ subscribers = []
22
+ path = 'subscribers'
23
+ loop do
24
+ response = http_access_with_token('GET', path)
25
+ response_subscribers = response['body'].map do |subscriber|
26
+ status = subscriber['status']
27
+ if status == 'active'
28
+ status = subscriber['sessionStatus']['online'] ? 'online' : 'offline'
29
+ end
30
+ Subscriber.new(
31
+ imsi: subscriber['imsi'],
32
+ created_at: subscriber['createdAt'],
33
+ tags: subscriber['tags'],
34
+ status: status
35
+ )
36
+ end
37
+ subscribers.concat(response_subscribers)
38
+ next_link = get_next_link(response['headers'])
39
+ break if next_link.nil?
40
+ path = next_link
41
+ end
42
+
43
+ @logger.debug('finish get subscribers')
44
+ subscribers
45
+ end
46
+
47
+ def get_sessions(imsi, from, to)
48
+ @logger.debug("start get sessions #{imsi}")
49
+ from_timestamp_ms = from.to_i * 1000
50
+ to_timestamp_ms = (to.to_i + Const::ONE_DAY_SEC) * 1000
51
+ sessions = []
52
+ path = "subscribers/#{imsi}/events/sessions"
53
+ loop do
54
+ response = http_access_with_token('GET', path)
55
+ break if response['body'].empty?
56
+
57
+ # Modifiedなど接続/切断以外のイベントは排除
58
+ response_sessions = response['body'].select { |session| session['event'] == 'Created' || session['event'] == 'Deleted' }
59
+ sessions.concat(response_sessions)
60
+
61
+ # fromの時間に入る直前までのセッションが取得できていたら終了する
62
+ break if sessions.length > 0 && sessions.last['time'] < from_timestamp_ms
63
+
64
+ next_link = get_next_link(response['headers'])
65
+ break if next_link.nil?
66
+ path = next_link
67
+ end
68
+
69
+ # to以降のセッションは除外
70
+ sessions = sessions.reject { |session| session['time'] >= to_timestamp_ms }
71
+
72
+ # from以前のセッションは最後のイベントをfromの開始時のイベントとして残し、他は除外する
73
+ before_from_sessions = sessions.select { |session| session['time'] < from_timestamp_ms }
74
+ sessions = sessions.reject { |session| session['time'] < from_timestamp_ms }
75
+ if before_from_sessions.length > 0
76
+ sessions.push(before_from_sessions.first.merge({'time' => from_timestamp_ms }))
77
+ else
78
+ sessions.push({'event' => 'Deleted', 'time' => from_timestamp_ms} )
79
+ end
80
+
81
+ @logger.debug("finish get sessions #{imsi}")
82
+ sessions
83
+ end
84
+
85
+ # エラーログを取得する
86
+ # from、toの指定が効いていない?
87
+ def get_logs(from, to)
88
+ @logger.debug("start get logs")
89
+
90
+ from_timestamp = from.to_i
91
+ to_timestamp = to.to_i + Const::ONE_DAY_SEC
92
+ logs = []
93
+ path = "logs?from=#{from_timestamp}&to=#{to_timestamp}"
94
+
95
+ loop do
96
+ response = http_access_with_token('GET', path)
97
+ break if response['body'].empty?
98
+ logs.concat(response['body'])
99
+ next_link = get_next_link(response['headers'])
100
+ break if next_link.nil?
101
+ path = next_link
102
+ end
103
+
104
+ @logger.debug("finish get logs")
105
+ logs = logs.select { |log| log['time'] >= from_timestamp * 1000 && log['time'] < to_timestamp * 1000 }
106
+ logs
107
+ end
108
+
109
+ def get_billing(target_months)
110
+ @logger.debug("start get billings")
111
+ billings = []
112
+
113
+ target_months.each do |month|
114
+ export_response = http_access_with_token('POST', "bills/#{month}/export?export_mode=sync")
115
+ export_url = export_response['body']['url']
116
+
117
+ # 1文字目はBOMのため除外する
118
+ csv_text = HttpClient.download(export_url)
119
+ csv_text.slice!(0)
120
+ CSV.parse(csv_text, headers: true ) do |ln|
121
+ billing = Billing.new(
122
+ imsi: ln['imsi'],
123
+ device_id: ln['deviceId'],
124
+ date: ln['date'],
125
+ bill_item_name: ln['billItemName'],
126
+ amount: ln['amount']
127
+ )
128
+ billings.push(billing)
129
+ end
130
+ end
131
+
132
+ @logger.debug("finish get billings")
133
+ billings
134
+ end
135
+
136
+ def get_traffic(target_months)
137
+ @logger.debug("start get traffics")
138
+
139
+ traffics = []
140
+
141
+ target_months.each do |month|
142
+ from = Time.parse(month + '01')
143
+ # 翌月の1日 - 1
144
+ to = from + (32 - (from + 31 * Const::ONE_DAY_SEC).mday) * Const::ONE_DAY_SEC - 1
145
+ body = { 'from' => from.to_i, 'to' => to.to_i, 'period' => 'day'}
146
+ export_response = http_access_with_token('POST', "stats/air/operators/#{@token['operatorId']}/export?export_mode=sync", nil, body)
147
+ export_url = export_response['body']['url']
148
+
149
+ # 1文字目はBOMのため除外する
150
+ csv_text = HttpClient.download(export_url)
151
+ csv_text.slice!(0)
152
+ CSV.parse(csv_text, headers: true ) do |ln|
153
+ traffic = Traffic.new(
154
+ imsi: ln['imsi'],
155
+ date: ln['date'],
156
+ type: ln['type'],
157
+ upload_byte_size_total: ln['uploadByteSizeTotal'],
158
+ download_byte_size_total: ln['downloadByteSizeTotal'],
159
+ )
160
+ traffics.push(traffic)
161
+ end
162
+ end
163
+
164
+ @logger.debug("finish get traffics")
165
+ traffics
166
+ end
167
+
168
+ def upload_harvest(device_id, device_secret, time, data)
169
+ @logger.debug("start upload harvest #{device_id}")
170
+
171
+ # device_secretはURLセーフは文字列ではないためエンコードが必要
172
+ path = "devices/#{device_id}/publish?device_secret=#{URI.encode_www_form_component(device_secret)}"
173
+ headers = nil
174
+ unless time.nil?
175
+ headers = { 'X-SORACOM-TIMESTAMP' => (time.to_i * 1000).to_s }
176
+ end
177
+ result = @http_client.post(path, headers, data)
178
+
179
+ @logger.debug("finish upload harvest #{device_id}")
180
+ result
181
+ end
182
+
183
+ private
184
+
185
+ # 認証IDと認証キーで認証する
186
+ def authenticate(auth_key_id, auth_key)
187
+ @logger.debug("start authenticate")
188
+
189
+ request_body = { authKeyId: auth_key_id, authKey: auth_key }
190
+ token_response = @http_client.post("auth", {}, request_body)
191
+
192
+ @logger.debug("finish authenticate")
193
+ token_response['body']
194
+ end
195
+
196
+ # トークン情報付でアクセスする
197
+ def http_access_with_token(method, path, headers = nil, body = nil)
198
+ headers = {} if headers.nil?
199
+ headers.merge!({
200
+ 'X-SORACOM-API-KEY' => @token['apiKey'],
201
+ 'X-SORACOM-TOKEN' => @token['token']
202
+ })
203
+ case method
204
+ when 'GET' then @http_client.get(path, headers)
205
+ when 'POST' then @http_client.post(path, headers, body)
206
+ else nil
207
+ end
208
+ end
209
+
210
+ def get_next_link(headers)
211
+ return nil unless headers.key?('link')
212
+ link_array = headers['link'].split(/\s*,\s*/)
213
+ next_link_text = link_array.find { |link| link.include?('rel=next') }
214
+ return nil if next_link_text.nil?
215
+ next_link_text[(next_link_text.index('<') + 1)...(next_link_text.index('>'))]
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,62 @@
1
+ module SoracomSummary
2
+ class Billing
3
+ attr_accessor :imsi, :device_id, :date, :bill_item_name, :amount
4
+ class << self
5
+ def summary(billings, time)
6
+ month_text = time.strftime('%Y%m')
7
+ day = time.strftime('%Y%m%d').to_i
8
+
9
+ # 対象期間の請求をフィルタする
10
+ month_billings = billings.select { |billing| billing.date[0, 6] == month_text && billing.date.to_i <= day }
11
+ day_billings = billings.select { |billing| billing.date.to_i == day }
12
+ billing_summary = { 'billings-month-total' => sum(month_billings), 'billings-day-total' => sum(day_billings) }
13
+
14
+ # 請求を項目ごとにグループ化する
15
+ month_billings_by_item = month_billings
16
+ .group_by { |billing| billing.bill_item_name }
17
+ .map { |item, group| ["billings-month-item-#{item}", sum(group) ]}
18
+ .to_h
19
+ billing_summary.merge!(month_billings_by_item)
20
+
21
+ day_billings_by_item = day_billings
22
+ .group_by { |billing| billing.bill_item_name }
23
+ .map { |item, group| ["billings-day-item-#{item}", sum(group) ]}
24
+ .to_h
25
+ billing_summary.merge!(day_billings_by_item)
26
+
27
+ billing_summary
28
+ end
29
+
30
+ def group_by_origin(billings, time)
31
+ day = time.strftime('%Y%m%d')
32
+
33
+ # 対象期間の請求をフィルタする
34
+ day_billings = billings.select { |billing| billing.date == day }
35
+
36
+ day_billings_by_origin = day_billings
37
+ .group_by { |billing| billing.imsi || billing.device_id || billing.bill_item_name }
38
+ .map { |origin, group| [origin, sum(group)] }
39
+ .to_h
40
+
41
+ day_billings_by_origin
42
+ end
43
+
44
+ def sum(billings)
45
+ billings.inject(0.0) { |sum, billing| sum + billing.amount.to_f }
46
+ end
47
+ end
48
+
49
+ def initialize(
50
+ imsi:,
51
+ device_id:,
52
+ date:,
53
+ bill_item_name:,
54
+ amount:)
55
+ @imsi = imsi
56
+ @device_id = device_id
57
+ @date = date
58
+ @bill_item_name = bill_item_name
59
+ @amount = amount
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,145 @@
1
+ require 'optparse'
2
+ require 'date'
3
+
4
+ module SoracomSummary
5
+ class CLI
6
+ class << self
7
+ def run(argv)
8
+ # ソラコム内では時間はUTCで表現されるため、タイムゾーンをUTCにしておく
9
+ ENV['TZ'] = 'UTC'
10
+ parse!(argv)
11
+ end
12
+
13
+ private
14
+
15
+ def parse!(argv)
16
+ options = {}
17
+ parser = create_parser(options)
18
+ parser.order!(argv)
19
+
20
+ if options.key?('from')
21
+ @from = Time.parse(options['from'])
22
+ else
23
+ @from = Date.today.to_time - Const::ONE_DAY_SEC
24
+ end
25
+
26
+ if options.key?('to')
27
+ @to = Time.parse(options['to']) + Const::ONE_DAY_SEC
28
+ else
29
+ @to = Date.today.to_time
30
+ end
31
+
32
+ if @from >= @to
33
+ raise ArgumentError, 'from must be less or equal to'
34
+ end
35
+
36
+ # 分類に使用するタグの設定
37
+ if options.key?('category_tag')
38
+ @category_tag = options['category_tag']
39
+ end
40
+
41
+ # セッション分析を実施するか
42
+ # 取得に時間を要するためオプションとする
43
+ if options.key?('session')
44
+ @session_analyze_enable = options['session']
45
+ end
46
+
47
+ scrape
48
+ end
49
+
50
+ def create_parser(options)
51
+ OptionParser.new do |opt|
52
+ opt.on_head('-v', '--version', 'display version') do
53
+ puts "soracom_summary #{VERSION}"
54
+ exit
55
+ end
56
+ opt.on('--from from_date', 'set from_date to scrape(default: today)') { |v| options['from'] = v }
57
+ opt.on('--to to_date', 'set to_date to scrape(default: today)') { |v| options['to'] = v }
58
+ opt.on('--tag category_tag', 'select tag to categorize(default: nil)') { |v| options['category_tag'] = v }
59
+ opt.on('--session', 'enable session analyze(default: false)') { |v| options['session'] = v }
60
+ end
61
+ end
62
+
63
+ def scrape
64
+ raise ArgumentError, 'need SORACOM_AUTH_KEY_ID environment' unless ENV.key?('SORACOM_AUTH_KEY_ID')
65
+ raise ArgumentError, 'need SORACOM_AUTH_KEY environment' unless ENV.key?('SORACOM_AUTH_KEY')
66
+ raise ArgumentError, 'need SORACOM_SUMMARY_DEVICE_ID environment' unless ENV.key?('SORACOM_SUMMARY_DEVICE_ID')
67
+ raise ArgumentError, 'need SORACOM_SUMMARY_DEVICE_SECRET environment' unless ENV.key?('SORACOM_SUMMARY_DEVICE_SECRET')
68
+
69
+ client = SoracomSummary::ApiClient.new(
70
+ auth_key_id: ENV['SORACOM_AUTH_KEY_ID'],
71
+ auth_key: ENV['SORACOM_AUTH_KEY'])
72
+
73
+ subscribers = client.get_subscribers
74
+
75
+ # セッション分析を実行する場合
76
+ if @session_analyze_enable == true
77
+ subscribers.each do |subscriber|
78
+ sessions = client.get_sessions(subscriber.imsi, @from, @to )
79
+ subscriber.sessions = sessions
80
+ end
81
+ end
82
+
83
+ target_months = get_target_months(@from, @to)
84
+ billings = client.get_billing(target_months)
85
+ traffics = client.get_traffic(target_months)
86
+ logs = client.get_logs(@from, @to)
87
+
88
+ time = @from
89
+ loop do
90
+ summary = get_summary(subscribers, billings, traffics, logs, time)
91
+ client.upload_harvest(ENV['SORACOM_SUMMARY_DEVICE_ID'], ENV['SORACOM_SUMMARY_DEVICE_SECRET'], time, summary)
92
+
93
+ if @session_analyze_enable == true && ENV.key?('SORACOM_SESSION_DEVICE_ID') && ENV.key?('SORACOM_SESSION_DEVICE_SECRET')
94
+ sessions_count_by_imsi = Subscriber.sessions_count_by_imsi(subscribers, time)
95
+ client.upload_harvest(ENV['SORACOM_SESSION_DEVICE_ID'], ENV['SORACOM_SESSION_DEVICE_SECRET'], time, sessions_count_by_imsi)
96
+ end
97
+
98
+ if ENV.key?('SORACOM_BILLING_DEVICE_ID') && ENV.key?('SORACOM_BILLING_DEVICE_SECRET')
99
+ billing_by_origin = Billing.group_by_origin(billings, time)
100
+ client.upload_harvest(ENV['SORACOM_BILLING_DEVICE_ID'], ENV['SORACOM_BILLING_DEVICE_SECRET'], time, billing_by_origin)
101
+ end
102
+
103
+ if ENV.key?('SORACOM_TRAFFIC_DEVICE_ID') && ENV.key?('SORACOM_TRAFFIC_DEVICE_SECRET')
104
+ traffic_by_imsi = Traffic.group_by_imsi(traffics, time)
105
+ client.upload_harvest(ENV['SORACOM_TRAFFIC_DEVICE_ID'], ENV['SORACOM_TRAFFIC_DEVICE_SECRET'], time, traffic_by_imsi)
106
+ end
107
+
108
+ time += Const::ONE_DAY_SEC
109
+ break if time >= @to
110
+ end
111
+ end
112
+
113
+ def get_summary(subscribers, billings, traffics, logs, time)
114
+ subscribers_summary = Subscriber.summary(subscribers, time, @category_tag, @session_analyze_enable)
115
+ billings_summary = Billing.summary(billings, time)
116
+ traffics_summary = Traffic.summary(traffics, time)
117
+ logs_summary = summary_log(logs, time)
118
+
119
+ result = subscribers_summary
120
+ .merge(billings_summary)
121
+ .merge(traffics_summary)
122
+ .merge(logs_summary)
123
+ result
124
+ end
125
+
126
+ def get_target_months(from, to)
127
+ target_months = []
128
+ time = from
129
+ loop do
130
+ target_months.push(time.strftime('%Y%m'))
131
+ time += Const::ONE_DAY_SEC
132
+ break if time >= to
133
+ end
134
+ target_months.uniq
135
+ end
136
+
137
+ def summary_log(logs, time)
138
+ target_logs = logs.select do |log|
139
+ log['time'] >= time.to_i * 1000 && log['time'] < time.to_i * 1000 + Const::ONE_DAY_MSEC
140
+ end
141
+ { 'error-log-count' => target_logs.length }
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,6 @@
1
+ module SoracomSummary
2
+ class Const
3
+ ONE_DAY_SEC = 24 * 60 * 60
4
+ ONE_DAY_MSEC = ONE_DAY_SEC * 1000
5
+ end
6
+ end
@@ -0,0 +1,100 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'logger'
5
+
6
+ module SoracomSummary
7
+ class HttpClient
8
+ class << self
9
+ def download(url)
10
+ uri = URI.parse(url)
11
+ request = Net::HTTP::Get.new(uri)
12
+ response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true }) do |http|
13
+ http.request(request)
14
+ end
15
+ if (response.code.start_with?('2'))
16
+ return response.body.force_encoding('UTF-8')
17
+ else
18
+ Logger.new(STDERR).warn("Cannot download csv")
19
+ raise
20
+ end
21
+ end
22
+ end
23
+
24
+ def initialize(base_url: , interval_second: 1, retry_limit: 5)
25
+ @logger = Logger.new(STDERR)
26
+ @logger.level = ENV['SORACOM_SUMMARY_DEBUG'] ? Logger::DEBUG : Logger::WARN
27
+ @base_url = base_url
28
+ @interval_second = interval_second
29
+ @retry_limit = retry_limit
30
+ end
31
+
32
+ def get(path, headers)
33
+ uri = URI.join(@base_url, path)
34
+ request = Net::HTTP::Get.new(uri)
35
+
36
+ if headers.class == Hash
37
+ headers.each do |key, value|
38
+ request[key] = value
39
+ end
40
+ end
41
+
42
+ access(uri, request)
43
+ end
44
+
45
+ def post(path, headers, request_body)
46
+ uri = URI.join(@base_url, path)
47
+ request = Net::HTTP::Post.new(uri)
48
+ request.content_type = 'application/json'
49
+
50
+ if headers.class == Hash
51
+ headers.each do |key, value|
52
+ request[key] = value
53
+ end
54
+ end
55
+
56
+ if !request_body.nil?
57
+ request.body = JSON.generate(request_body)
58
+ end
59
+
60
+ access(uri, request)
61
+ end
62
+
63
+ private
64
+
65
+ def access(uri, request, retry_count = 0)
66
+ response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true }) do |http|
67
+ http.request(request)
68
+ end
69
+
70
+ if (response.code.start_with?('2'))
71
+ # 成功した場合はヘッダーとボディをそれぞれ返す
72
+ # ヘッダーを返すのはlinkヘッダーの取得が必要となるため
73
+ # ボディは基本的にはJSONでパースできるが、空の場合は出来ないため空文字列で返す
74
+ # 連続してアクセスしすぎないよう規定時間スリープしてから応答する
75
+ sleep @interval_second
76
+ return { 'headers' => response.header, 'body' => response.body.empty? ? '' : JSON.parse(response.body)}
77
+ elsif response.code == '429'
78
+ # 429はAPIレート制限のため、1分待ってからエラーを発生させる
79
+ @logger.debug("#{response.code}: #{response.body}")
80
+ sleep 60
81
+ raise
82
+ else
83
+ # その他不明なコードが返った場合はエラーを発生させる
84
+ @logger.warn("#{response.code}: #{response.body}")
85
+ raise
86
+ end
87
+ rescue => e
88
+ @logger.warn e
89
+ # エラーが発生した場合はリトライ回数が規定値未満であればリトライする
90
+ retry_count += 1
91
+ if retry_count < @retry_limit
92
+ sleep (2 ** retry_count)
93
+ retry
94
+ end
95
+
96
+ # リトライ回数が規定値を超えるとエラーを上位に報告する
97
+ raise
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,88 @@
1
+ module SoracomSummary
2
+ class Subscriber
3
+ attr_accessor :imsi, :created_at, :tags, :status, :sessions
4
+ class << self
5
+ def summary(subscribers, time, category_tag = nil, session_analyze_enable = false)
6
+ # 指定期間内に登録されていたSIMのみ対象とする
7
+ target_subscribers = subscribers.select do |subscriber|
8
+ subscriber.created_at < time.to_i * 1000 + Const::ONE_DAY_MSEC
9
+ end
10
+
11
+ subscribers_summary = {
12
+ 'subscribers-total' => target_subscribers.length
13
+ }
14
+
15
+ # ステータスでの分類
16
+ subscribers_count_by_status = target_subscribers
17
+ .group_by { |subscriber| subscriber.status }
18
+ .map { |status, group| ["subscribers-status-#{status}", group.length ]}
19
+ .to_h
20
+ subscribers_summary.merge!(subscribers_count_by_status)
21
+
22
+ # 分類対象のタグがセットされていたらタグで分類する
23
+ if !category_tag.nil?
24
+ subscribers_count_by_tag = target_subscribers
25
+ .group_by { |subscriber| subscriber.tags[category_tag] }
26
+ .map { |tag, group| ["subscribers-#{category_tag}-#{(tag.nil? ? "none" : tag)}", group.length ]}
27
+ .to_h
28
+ subscribers_summary.merge!(subscribers_count_by_tag)
29
+ end
30
+
31
+ # セッション状態での分類
32
+ if session_analyze_enable
33
+ subscribers_count_by_active = target_subscribers
34
+ .group_by { |subscriber| subscriber.active?(time) }
35
+ .map { |active, group| ["subscribers-#{active ? 'active' : 'inactive'}", group.length ]}
36
+ .to_h
37
+ subscribers_summary.merge!(subscribers_count_by_active)
38
+ end
39
+ subscribers_summary
40
+ end
41
+
42
+ # imsiごとのセッション数を取得する
43
+ def sessions_count_by_imsi(subscribers, time)
44
+ subscribers
45
+ .map { |subscriber| [ subscriber.imsi, subscriber.sessions_count(time) ]}
46
+ .to_h
47
+ end
48
+ end
49
+
50
+ def initialize(
51
+ imsi:,
52
+ created_at:,
53
+ tags:,
54
+ status:)
55
+ @imsi = imsi
56
+ @created_at = created_at
57
+ @tags = tags
58
+ @status = status
59
+ @sessions = []
60
+ end
61
+
62
+ def active?(time)
63
+ from_timestamp_ms = time.to_i * 1000
64
+ to_timestamp_ms = time.to_i * 1000 + Const::ONE_DAY_MSEC
65
+
66
+ # 対象期間内にCreatedのイベントがあればアクティブ
67
+ judge = @sessions.any? do |session|
68
+ session['time'] >= from_timestamp_ms && session['time'] < to_timestamp_ms && session['event'] == 'Created'
69
+ end
70
+ return true if judge == true
71
+
72
+ # 対象期間内の直前のイベントがCreatedであればアクティブ
73
+ sessions_before_from = @sessions.select { |session| session['time'] < from_timestamp_ms }
74
+ return false if sessions_before_from.length == 0
75
+ sessions_before_from.first['event'] == 'Created'
76
+ end
77
+
78
+ def sessions_count(time)
79
+ from_timestamp_ms = time.to_i * 1000
80
+ to_timestamp_ms = time.to_i * 1000 + Const::ONE_DAY_MSEC
81
+
82
+ created_sessions = @sessions.select do |session|
83
+ session['time'] >= from_timestamp_ms && session['time'] < to_timestamp_ms && session['event'] == 'Created'
84
+ end
85
+ created_sessions.length
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,90 @@
1
+ module SoracomSummary
2
+ class Traffic
3
+ attr_accessor :imsi, :date, :type, :upload_byte_size_total, :download_byte_size_total
4
+ class << self
5
+ def summary(traffics, time)
6
+ month_text = time.strftime('%Y%m')
7
+ day = time.strftime('%Y%m%d').to_i
8
+
9
+ # 対象期間の請求をフィルタする
10
+ month_traffics = traffics.select { |traffic| traffic.date[0, 6] == month_text && traffic.date.to_i <= day }
11
+ day_traffics = traffics.select { |traffic| traffic.date.to_i == day }
12
+
13
+ traffic_summary = {
14
+ 'traffics-month-total' => sum(month_traffics),
15
+ 'traffics-month-upload' => upload_sum(month_traffics),
16
+ 'traffics-month-download' => download_sum(month_traffics),
17
+ 'traffics-day-total' => sum(day_traffics),
18
+ 'traffics-day-upload' => upload_sum(day_traffics),
19
+ 'traffics-day-download' => download_sum(day_traffics)
20
+ }
21
+
22
+ # 通信量を種類ごとにグループ化する
23
+ month_traffics_by_type = month_traffics
24
+ .group_by { |traffic| traffic.type }
25
+ .map do |type, group|
26
+ [
27
+ "traffics-month-total-#{type}", sum(group),
28
+ "traffics-month-upload-#{type}", upload_sum(group),
29
+ "traffics-month-download-#{type}", download_sum(group)
30
+ ]
31
+ end.flatten
32
+ traffic_summary.merge!(Hash[*month_traffics_by_type])
33
+
34
+ day_traffics_by_type = day_traffics
35
+ .group_by { |traffic| traffic.type }
36
+ .map do |type, group|
37
+ [
38
+ "traffics-day-total-#{type}", sum(group),
39
+ "traffics-day-upload-#{type}", upload_sum(group),
40
+ "traffics-day-download-#{type}", download_sum(group)
41
+ ]
42
+ end.flatten
43
+ traffic_summary.merge!(Hash[*day_traffics_by_type])
44
+
45
+ traffic_summary
46
+ end
47
+
48
+ def group_by_imsi(traffics, time)
49
+ day = time.strftime('%Y%m%d')
50
+
51
+ # 対象期間の請求をフィルタする
52
+ day_traffics = traffics.select { |traffic| traffic.date == day }
53
+
54
+ day_traffics_by_imsi = day_traffics
55
+ .group_by { |traffic| traffic.imsi }
56
+ .map { |imsi, group| [imsi, sum(group)] }
57
+ .to_h
58
+
59
+ day_traffics_by_imsi
60
+ end
61
+
62
+ def sum(traffics)
63
+ traffics.inject(0) do |sum, traffic|
64
+ sum + traffic.upload_byte_size_total.to_i + traffic.download_byte_size_total.to_i
65
+ end
66
+ end
67
+
68
+ def upload_sum(traffics)
69
+ traffics.inject(0) { |sum, traffic| sum + traffic.upload_byte_size_total.to_i }
70
+ end
71
+
72
+ def download_sum(traffics)
73
+ traffics.inject(0) { |sum, traffic| sum + traffic.download_byte_size_total.to_i }
74
+ end
75
+ end
76
+
77
+ def initialize(
78
+ imsi:,
79
+ date:,
80
+ type:,
81
+ upload_byte_size_total:,
82
+ download_byte_size_total:)
83
+ @imsi = imsi
84
+ @date = date
85
+ @type = type
86
+ @upload_byte_size_total = upload_byte_size_total
87
+ @download_byte_size_total = download_byte_size_total
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,3 @@
1
+ module SoracomSummary
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,28 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "soracom_summary/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "soracom_summary"
7
+ spec.version = SoracomSummary::VERSION
8
+ spec.authors = ["1stship"]
9
+ spec.email = ["1peifunyaq@gmail.com"]
10
+
11
+ spec.summary = 'tool to create SORACOM usage summary'
12
+ spec.description = 'Scraping SORACOM usage via SORACOM API and uploading these data to SORACOM Harvest'
13
+ spec.homepage = 'https://github.com/1stship/soracom-summary/'
14
+ spec.license = "MIT"
15
+
16
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
17
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_development_dependency "bundler", "~> 2.0"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec", "~> 3.0"
26
+ spec.add_development_dependency 'ruby-debug-ide'
27
+ spec.add_development_dependency 'debase'
28
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: soracom_summary
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 1stship
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-11-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: ruby-debug-ide
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: debase
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Scraping SORACOM usage via SORACOM API and uploading these data to SORACOM
84
+ Harvest
85
+ email:
86
+ - 1peifunyaq@gmail.com
87
+ executables:
88
+ - soracom_summary
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - ".gitignore"
93
+ - ".rspec"
94
+ - ".travis.yml"
95
+ - CODE_OF_CONDUCT.md
96
+ - Gemfile
97
+ - Gemfile.lock
98
+ - LICENSE.txt
99
+ - README.md
100
+ - Rakefile
101
+ - bin/console
102
+ - bin/setup
103
+ - exe/soracom_summary
104
+ - lib/soracom_summary.rb
105
+ - lib/soracom_summary/api_client.rb
106
+ - lib/soracom_summary/billing.rb
107
+ - lib/soracom_summary/cli.rb
108
+ - lib/soracom_summary/const.rb
109
+ - lib/soracom_summary/http_client.rb
110
+ - lib/soracom_summary/subscriber.rb
111
+ - lib/soracom_summary/traffic.rb
112
+ - lib/soracom_summary/version.rb
113
+ - soracom_summary.gemspec
114
+ homepage: https://github.com/1stship/soracom-summary/
115
+ licenses:
116
+ - MIT
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubyforge_project:
134
+ rubygems_version: 2.6.12
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: tool to create SORACOM usage summary
138
+ test_files: []