doow_track 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +150 -0
- data/lib/doow_track/error.rb +33 -0
- data/lib/doow_track/management.rb +255 -0
- data/lib/doow_track/tracker.rb +148 -0
- data/lib/doow_track/types.rb +153 -0
- data/lib/doow_track/version.rb +5 -0
- data/lib/doow_track.rb +20 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: be141768a653090f70f87d0970d3e5bfbfbfcb58379b98ac45e90c144ee77eb9
|
|
4
|
+
data.tar.gz: d8582db5050a500a37fa1e747b2d84a72ac832b3e97e1ade7146081637639365
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c2781c02faae7ee84799985834197da9c84fbe0bae637b8dc326e038d79d7d6938348a7b2db94bf8f1453c645fdc8d905049c6d46f31cfc30c7d627683aafa5a
|
|
7
|
+
data.tar.gz: 076d1ff26f3214a496ec16b5ce764e72a362582487d8fa7971b3f6dda9f306eae5509310b7eb18013bd7e35d3eebb98fddffc1673cd2af4a267b4d272544e7ad
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Doow
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Doow Track Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/doow_track)
|
|
4
|
+
[](https://www.ruby-lang.org/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Official Ruby SDK for [Doow](https://doow.co) usage telemetry and management.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
| Feature | Description |
|
|
12
|
+
|---------|-------------|
|
|
13
|
+
| **Ruby 3.0+** | Modern Ruby with pattern matching and endless methods |
|
|
14
|
+
| **Batching** | Events queued and sent in configurable batches |
|
|
15
|
+
| **Compression** | Automatic gzip for payloads >1KB |
|
|
16
|
+
| **Retries** | Exponential backoff with configurable retry count |
|
|
17
|
+
| **Type Safety** | Struct-based models |
|
|
18
|
+
| **Sidecar** | Executable for stdin/file/tcp input modes |
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'doow_track'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or install directly:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gem install doow_track
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Track Usage Events
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require 'doow_track'
|
|
42
|
+
|
|
43
|
+
tracker = DoowTrack::Tracker.new('dk_your_api_key')
|
|
44
|
+
|
|
45
|
+
# Simple event
|
|
46
|
+
tracker.track(DoowTrack::TrackEvent.new(
|
|
47
|
+
metric: 'api_calls',
|
|
48
|
+
quantity: 1,
|
|
49
|
+
license_id: 'lic_abc123'
|
|
50
|
+
))
|
|
51
|
+
|
|
52
|
+
# Rich event with metadata
|
|
53
|
+
tracker.track(DoowTrack::TrackEvent.new(
|
|
54
|
+
metric: 'tokens_generated',
|
|
55
|
+
quantity: 1500,
|
|
56
|
+
license_id: 'lic_abc123',
|
|
57
|
+
unit: 'tokens',
|
|
58
|
+
attribution: { model: 'gpt-4', region: 'us-east-1' },
|
|
59
|
+
metadata: { request_id: 'req_xyz' }
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
tracker.shutdown
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Management API
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
require 'doow_track'
|
|
69
|
+
|
|
70
|
+
mgmt = DoowTrack::Management.new('dk_your_api_key')
|
|
71
|
+
|
|
72
|
+
# Create an app
|
|
73
|
+
app = mgmt.apps.create(
|
|
74
|
+
name: 'My SaaS Platform',
|
|
75
|
+
description: 'Usage-based billing'
|
|
76
|
+
)
|
|
77
|
+
puts "Created app: #{app.id}"
|
|
78
|
+
|
|
79
|
+
# Create a contract with license
|
|
80
|
+
contract = mgmt.contracts.create(app.id,
|
|
81
|
+
title: 'Enterprise Plan',
|
|
82
|
+
contract_type: DoowTrack::ContractType::PAY_AS_YOU_GO,
|
|
83
|
+
licenses: [
|
|
84
|
+
{ name: 'API Usage', license_type: DoowTrack::LicenseType::USAGE_BASED }
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
license_id = contract.licenses.first.id
|
|
89
|
+
|
|
90
|
+
# Create a metric
|
|
91
|
+
metric = mgmt.metrics.create(license_id, metric_type: 'api_calls')
|
|
92
|
+
puts "Created metric: #{metric.id}"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
### Tracker Options
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
tracker = DoowTrack::Tracker.new('dk_your_api_key',
|
|
103
|
+
endpoint: 'https://api.doow.co',
|
|
104
|
+
enabled: true,
|
|
105
|
+
debug: true,
|
|
106
|
+
flush_at: 20,
|
|
107
|
+
flush_interval: 10,
|
|
108
|
+
max_queue_size: 10_000,
|
|
109
|
+
timeout: 10,
|
|
110
|
+
retry_count: 3,
|
|
111
|
+
disable_compression: false,
|
|
112
|
+
attribution: { service: 'api-gateway' },
|
|
113
|
+
on_error: ->(e) { Rails.logger.error("Tracker error: #{e.message}") }
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Environment Variables
|
|
118
|
+
|
|
119
|
+
| Variable | Description | Default |
|
|
120
|
+
|----------|-------------|---------|
|
|
121
|
+
| `DOOW_TRACK_API_KEY` | API key (overrides constructor) | — |
|
|
122
|
+
| `DOOW_TRACK_ENDPOINT` | Custom API endpoint | `https://api.doow.co` |
|
|
123
|
+
| `DOOW_TRACK_DISABLED` | Set `true` to disable tracking | `false` |
|
|
124
|
+
| `DOOW_TRACK_DEBUG` | Set `true` for debug logs | `false` |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Error Handling
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
begin
|
|
132
|
+
app = mgmt.apps.get('invalid_id')
|
|
133
|
+
rescue DoowTrack::Error => e
|
|
134
|
+
if e.not_found?
|
|
135
|
+
puts "App not found"
|
|
136
|
+
elsif e.unauthorized?
|
|
137
|
+
puts "Invalid API key"
|
|
138
|
+
elsif e.rate_limited?
|
|
139
|
+
puts "Rate limited"
|
|
140
|
+
else
|
|
141
|
+
puts "Error: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DoowTrack
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :status_code, :error_class
|
|
6
|
+
|
|
7
|
+
def initialize(message, status_code: 0, error_class: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@status_code = status_code
|
|
10
|
+
@error_class = error_class
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def not_found?
|
|
14
|
+
status_code == 404
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def unauthorized?
|
|
18
|
+
status_code == 401
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def forbidden?
|
|
22
|
+
status_code == 403
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rate_limited?
|
|
26
|
+
status_code == 429
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def server_error?
|
|
30
|
+
status_code >= 500
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module DoowTrack
|
|
8
|
+
class Management
|
|
9
|
+
DEFAULT_OPTIONS = {
|
|
10
|
+
endpoint: "https://api.doow.co",
|
|
11
|
+
timeout: 30,
|
|
12
|
+
retry_count: 3,
|
|
13
|
+
debug: false
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(api_key, **options)
|
|
17
|
+
@api_key = ENV["DOOW_TRACK_API_KEY"] || api_key
|
|
18
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
19
|
+
@options[:endpoint] = ENV["DOOW_TRACK_ENDPOINT"] if ENV["DOOW_TRACK_ENDPOINT"]
|
|
20
|
+
@options[:debug] = true if ENV["DOOW_TRACK_DEBUG"] == "true"
|
|
21
|
+
|
|
22
|
+
raise Error, "Invalid API key format. Must start with 'dk_'." unless @api_key&.start_with?("dk_")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apps
|
|
26
|
+
AppsResource.new(self)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def contracts
|
|
30
|
+
ContractsResource.new(self)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def licenses
|
|
34
|
+
LicensesResource.new(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def metrics
|
|
38
|
+
MetricsResource.new(self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def expenses
|
|
42
|
+
ExpensesResource.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def request(method, path, body: nil)
|
|
46
|
+
url = URI("#{@options[:endpoint].chomp('/')}/sdk#{path}")
|
|
47
|
+
log("[doow/management] #{method.upcase} #{path}")
|
|
48
|
+
|
|
49
|
+
(@options[:retry_count] + 1).times do |attempt|
|
|
50
|
+
begin
|
|
51
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
52
|
+
http.use_ssl = url.scheme == "https"
|
|
53
|
+
http.open_timeout = @options[:timeout]
|
|
54
|
+
http.read_timeout = @options[:timeout]
|
|
55
|
+
|
|
56
|
+
request = case method
|
|
57
|
+
when :get then Net::HTTP::Get.new(url)
|
|
58
|
+
when :post then Net::HTTP::Post.new(url)
|
|
59
|
+
when :patch then Net::HTTP::Patch.new(url)
|
|
60
|
+
when :delete then Net::HTTP::Delete.new(url)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
64
|
+
request["Content-Type"] = "application/json"
|
|
65
|
+
request.body = body.to_json if body
|
|
66
|
+
|
|
67
|
+
response = http.request(request)
|
|
68
|
+
|
|
69
|
+
if response.code.to_i >= 200 && response.code.to_i < 300
|
|
70
|
+
return nil if response.body.nil? || response.body.empty?
|
|
71
|
+
return JSON.parse(response.body, symbolize_names: true)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if response.code.to_i >= 500 && attempt < @options[:retry_count]
|
|
75
|
+
sleep(2**attempt)
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
raise Error.new("API error: #{response.body}", status_code: response.code.to_i)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
raise if e.is_a?(Error)
|
|
82
|
+
if attempt < @options[:retry_count]
|
|
83
|
+
sleep(2**attempt)
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
raise Error.new("Request failed: #{e.message}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def log(message)
|
|
94
|
+
$stderr.puts(message) if @options[:debug]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class AppsResource
|
|
99
|
+
def initialize(mgmt)
|
|
100
|
+
@mgmt = mgmt
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def list(cursor: nil, limit: 50)
|
|
104
|
+
path = "/apps?limit=#{limit}"
|
|
105
|
+
path += "&cursor=#{cursor}" if cursor
|
|
106
|
+
data = @mgmt.request(:get, path)
|
|
107
|
+
parse_paginated(data, App)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def get(id)
|
|
111
|
+
data = @mgmt.request(:get, "/apps/#{id}")
|
|
112
|
+
App.new(**data)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def create(name:, **attrs)
|
|
116
|
+
data = @mgmt.request(:post, "/apps", body: { name: name, **attrs })
|
|
117
|
+
App.new(**data)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def update(id, **attrs)
|
|
121
|
+
data = @mgmt.request(:patch, "/apps/#{id}", body: attrs)
|
|
122
|
+
App.new(**data)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def delete(id)
|
|
126
|
+
@mgmt.request(:delete, "/apps/#{id}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def parse_paginated(data, klass)
|
|
132
|
+
PaginatedResponse.new(
|
|
133
|
+
data: data[:data].map { |d| klass.new(**d) },
|
|
134
|
+
next_cursor: data[:next_cursor],
|
|
135
|
+
has_more: data[:has_more]
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class ContractsResource
|
|
141
|
+
def initialize(mgmt)
|
|
142
|
+
@mgmt = mgmt
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def list_by_app(app_id, cursor: nil, limit: 50)
|
|
146
|
+
path = "/apps/#{app_id}/contracts?limit=#{limit}"
|
|
147
|
+
path += "&cursor=#{cursor}" if cursor
|
|
148
|
+
data = @mgmt.request(:get, path)
|
|
149
|
+
parse_paginated(data)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get(id)
|
|
153
|
+
data = @mgmt.request(:get, "/contracts/#{id}")
|
|
154
|
+
parse_contract(data)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def create(app_id, title:, contract_type: ContractType::PAY_AS_YOU_GO, licenses: [])
|
|
158
|
+
data = @mgmt.request(:post, "/apps/#{app_id}/contracts", body: {
|
|
159
|
+
title: title,
|
|
160
|
+
contract_type: contract_type,
|
|
161
|
+
licenses: licenses
|
|
162
|
+
})
|
|
163
|
+
parse_contract(data)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def delete(id)
|
|
167
|
+
@mgmt.request(:delete, "/contracts/#{id}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def parse_contract(data)
|
|
173
|
+
licenses = (data[:licenses] || []).map { |l| License.new(**l) }
|
|
174
|
+
Contract.new(**data.merge(licenses: licenses))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_paginated(data)
|
|
178
|
+
PaginatedResponse.new(
|
|
179
|
+
data: data[:data].map { |d| parse_contract(d) },
|
|
180
|
+
next_cursor: data[:next_cursor],
|
|
181
|
+
has_more: data[:has_more]
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class LicensesResource
|
|
187
|
+
def initialize(mgmt)
|
|
188
|
+
@mgmt = mgmt
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def list(cursor: nil, limit: 50)
|
|
192
|
+
path = "/licenses?limit=#{limit}"
|
|
193
|
+
path += "&cursor=#{cursor}" if cursor
|
|
194
|
+
data = @mgmt.request(:get, path)
|
|
195
|
+
PaginatedResponse.new(
|
|
196
|
+
data: data[:data].map { |d| License.new(**d) },
|
|
197
|
+
next_cursor: data[:next_cursor],
|
|
198
|
+
has_more: data[:has_more]
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def get(id)
|
|
203
|
+
data = @mgmt.request(:get, "/licenses/#{id}")
|
|
204
|
+
License.new(**data)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class MetricsResource
|
|
209
|
+
def initialize(mgmt)
|
|
210
|
+
@mgmt = mgmt
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def list_by_license(license_id, cursor: nil, limit: 50)
|
|
214
|
+
path = "/licenses/#{license_id}/metrics?limit=#{limit}"
|
|
215
|
+
path += "&cursor=#{cursor}" if cursor
|
|
216
|
+
data = @mgmt.request(:get, path)
|
|
217
|
+
PaginatedResponse.new(
|
|
218
|
+
data: data[:data].map { |d| Metric.new(**d) },
|
|
219
|
+
next_cursor: data[:next_cursor],
|
|
220
|
+
has_more: data[:has_more]
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def get(id)
|
|
225
|
+
data = @mgmt.request(:get, "/metrics/#{id}")
|
|
226
|
+
Metric.new(**data)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def create(license_id, metric_type:, **attrs)
|
|
230
|
+
data = @mgmt.request(:post, "/licenses/#{license_id}/metrics", body: { metric_type: metric_type, **attrs })
|
|
231
|
+
Metric.new(**data)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def delete(id)
|
|
235
|
+
@mgmt.request(:delete, "/metrics/#{id}")
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
class ExpensesResource
|
|
240
|
+
def initialize(mgmt)
|
|
241
|
+
@mgmt = mgmt
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def list_by_app(app_id, cursor: nil, limit: 50)
|
|
245
|
+
path = "/apps/#{app_id}/expenses?limit=#{limit}"
|
|
246
|
+
path += "&cursor=#{cursor}" if cursor
|
|
247
|
+
data = @mgmt.request(:get, path)
|
|
248
|
+
PaginatedResponse.new(
|
|
249
|
+
data: data[:data].map { |d| Expense.new(**d) },
|
|
250
|
+
next_cursor: data[:next_cursor],
|
|
251
|
+
has_more: data[:has_more]
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module DoowTrack
|
|
9
|
+
class Tracker
|
|
10
|
+
DEFAULT_OPTIONS = {
|
|
11
|
+
endpoint: "https://api.doow.co",
|
|
12
|
+
enabled: true,
|
|
13
|
+
debug: false,
|
|
14
|
+
flush_at: 20,
|
|
15
|
+
flush_interval: 10,
|
|
16
|
+
max_queue_size: 10_000,
|
|
17
|
+
timeout: 10,
|
|
18
|
+
retry_count: 3,
|
|
19
|
+
disable_compression: false,
|
|
20
|
+
attribution: nil,
|
|
21
|
+
on_error: nil
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(api_key, **options)
|
|
25
|
+
@api_key = ENV["DOOW_TRACK_API_KEY"] || api_key
|
|
26
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
27
|
+
@options[:endpoint] = ENV["DOOW_TRACK_ENDPOINT"] if ENV["DOOW_TRACK_ENDPOINT"]
|
|
28
|
+
@options[:enabled] = false if ENV["DOOW_TRACK_DISABLED"] == "true"
|
|
29
|
+
@options[:debug] = true if ENV["DOOW_TRACK_DEBUG"] == "true"
|
|
30
|
+
|
|
31
|
+
raise Error, "Invalid API key format. Must start with 'dk_'." unless @api_key&.start_with?("dk_")
|
|
32
|
+
|
|
33
|
+
@buffer = []
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@flusher = start_flusher
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def track(event)
|
|
39
|
+
return unless @options[:enabled]
|
|
40
|
+
|
|
41
|
+
final_event = event.is_a?(Hash) ? TrackEvent.new(**event) : event
|
|
42
|
+
final_event = TrackEvent.new(
|
|
43
|
+
**final_event.to_h.merge(
|
|
44
|
+
timestamp: final_event.timestamp || Time.now.utc,
|
|
45
|
+
attribution: merge_attribution(final_event.attribution)
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
if @buffer.size >= @options[:max_queue_size]
|
|
51
|
+
log("[doow-track] Queue full, dropping event")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
@buffer << final_event
|
|
55
|
+
flush_async if @buffer.size >= @options[:flush_at]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def flush
|
|
60
|
+
batch = nil
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
return if @buffer.empty?
|
|
63
|
+
batch = @buffer.dup
|
|
64
|
+
@buffer.clear
|
|
65
|
+
end
|
|
66
|
+
send_batch(batch) if batch
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def shutdown
|
|
70
|
+
@flusher&.kill
|
|
71
|
+
flush
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def start_flusher
|
|
77
|
+
return nil if @options[:flush_interval] <= 0
|
|
78
|
+
|
|
79
|
+
Thread.new do
|
|
80
|
+
loop do
|
|
81
|
+
sleep @options[:flush_interval]
|
|
82
|
+
flush
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flush_async
|
|
88
|
+
Thread.new { flush }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send_batch(batch)
|
|
92
|
+
url = URI("#{@options[:endpoint].chomp('/')}/telemetry/events")
|
|
93
|
+
payload = { events: batch.map(&:to_h) }
|
|
94
|
+
json = payload.to_json
|
|
95
|
+
|
|
96
|
+
(@options[:retry_count] + 1).times do |attempt|
|
|
97
|
+
begin
|
|
98
|
+
http = Net::HTTP.new(url.host, url.port)
|
|
99
|
+
http.use_ssl = url.scheme == "https"
|
|
100
|
+
http.open_timeout = @options[:timeout]
|
|
101
|
+
http.read_timeout = @options[:timeout]
|
|
102
|
+
|
|
103
|
+
request = Net::HTTP::Post.new(url)
|
|
104
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
105
|
+
request["Content-Type"] = "application/json"
|
|
106
|
+
|
|
107
|
+
body = json
|
|
108
|
+
if !@options[:disable_compression] && json.bytesize > 1024
|
|
109
|
+
body = Zlib::Deflate.deflate(json, Zlib::DEFAULT_COMPRESSION)
|
|
110
|
+
request["Content-Encoding"] = "gzip"
|
|
111
|
+
end
|
|
112
|
+
request.body = body
|
|
113
|
+
|
|
114
|
+
response = http.request(request)
|
|
115
|
+
|
|
116
|
+
if response.code.to_i >= 200 && response.code.to_i < 300
|
|
117
|
+
log("[doow-track] Flushed #{batch.size} events")
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if response.code.to_i >= 500 && attempt < @options[:retry_count]
|
|
122
|
+
sleep(2**attempt)
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
raise Error.new("API error: #{response.body}", status_code: response.code.to_i)
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
if attempt < @options[:retry_count]
|
|
129
|
+
sleep(2**attempt)
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
@options[:on_error]&.call(e)
|
|
133
|
+
log("[doow-track] Error: #{e.message}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def merge_attribution(event_attribution)
|
|
139
|
+
return event_attribution if @options[:attribution].nil?
|
|
140
|
+
return @options[:attribution] if event_attribution.nil?
|
|
141
|
+
@options[:attribution].merge(event_attribution)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def log(message)
|
|
145
|
+
$stderr.puts(message) if @options[:debug]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DoowTrack
|
|
4
|
+
module EventKind
|
|
5
|
+
USAGE = "USAGE"
|
|
6
|
+
ADJUSTMENT = "ADJUSTMENT"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ContractType
|
|
10
|
+
PAY_AS_YOU_GO = "PAY_AS_YOU_GO"
|
|
11
|
+
ENTERPRISE = "ENTERPRISE"
|
|
12
|
+
UNKNOWN = "UNKNOWN"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# License types for SDK creation. SDK only supports USAGE_BASED.
|
|
16
|
+
module LicenseType
|
|
17
|
+
USAGE_BASED = "USAGE_BASED"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# All license types (for responses that may include UI-created licenses)
|
|
21
|
+
module AllLicenseType
|
|
22
|
+
USAGE_BASED = "USAGE_BASED"
|
|
23
|
+
SEAT_BASED = "SEAT_BASED"
|
|
24
|
+
PREPAID_CREDITS = "PREPAID_CREDITS"
|
|
25
|
+
FLAT_RATE = "FLAT_RATE"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module UsageAggregationType
|
|
29
|
+
SUM = "SUM"
|
|
30
|
+
MAX = "MAX"
|
|
31
|
+
CUMULATIVE = "CUMULATIVE"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module RateKind
|
|
35
|
+
PER_UNIT = "PER_UNIT"
|
|
36
|
+
FLAT_FEE = "FLAT_FEE"
|
|
37
|
+
PER_SEAT = "PER_SEAT"
|
|
38
|
+
TIERED = "TIERED"
|
|
39
|
+
VOLUME = "VOLUME"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module EntitlementPeriod
|
|
43
|
+
MONTHLY = "MONTHLY"
|
|
44
|
+
YEARLY = "YEARLY"
|
|
45
|
+
QUARTERLY = "QUARTERLY"
|
|
46
|
+
WEEKLY = "WEEKLY"
|
|
47
|
+
DAILY = "DAILY"
|
|
48
|
+
ONE_TIME = "ONE_TIME"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module CarryoverPolicy
|
|
52
|
+
EXPIRE_AT_PERIOD_END = "EXPIRE_AT_PERIOD_END"
|
|
53
|
+
ROLLOVER = "ROLLOVER"
|
|
54
|
+
ROLLOVER_CAPPED = "ROLLOVER_CAPPED"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module CostAmortization
|
|
58
|
+
PRORATA = "PRORATA"
|
|
59
|
+
MONTHS = "MONTHS"
|
|
60
|
+
QUARTER = "QUARTER"
|
|
61
|
+
YEARS = "YEARS"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
MetricTupleHint = Struct.new(:app_name, :license_name, :metric_name, keyword_init: true) do
|
|
65
|
+
def to_h
|
|
66
|
+
{ app_name: app_name, license_name: license_name, metric_name: metric_name }.compact
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
TrackEvent = Struct.new(
|
|
71
|
+
:metric, :quantity, :license_id, :unit, :kind,
|
|
72
|
+
:timestamp, :source_system, :metric_tuple_hint,
|
|
73
|
+
:attribution, :metadata,
|
|
74
|
+
keyword_init: true
|
|
75
|
+
) do
|
|
76
|
+
def to_h
|
|
77
|
+
{
|
|
78
|
+
metric: metric,
|
|
79
|
+
quantity: quantity,
|
|
80
|
+
license_id: license_id,
|
|
81
|
+
unit: unit,
|
|
82
|
+
kind: kind || EventKind::USAGE,
|
|
83
|
+
timestamp: timestamp&.iso8601 || Time.now.utc.iso8601,
|
|
84
|
+
source_system: source_system,
|
|
85
|
+
metric_tuple_hint: metric_tuple_hint&.to_h,
|
|
86
|
+
attribution: attribution,
|
|
87
|
+
metadata: metadata
|
|
88
|
+
}.compact
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class App
|
|
93
|
+
attr_accessor :id, :name, :description, :logo_url, :website, :categories, :saas_application_id, :created_at, :updated_at
|
|
94
|
+
|
|
95
|
+
def initialize(**attrs)
|
|
96
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class License
|
|
101
|
+
attr_accessor :id, :name, :license_type, :contract_id, :seats, :price_per_seat, :total_cost,
|
|
102
|
+
:discount, :discount_amount, :discounted_cost, :original_cost, :tenure,
|
|
103
|
+
:seats_included_in_base_plan, :created_at, :updated_at
|
|
104
|
+
|
|
105
|
+
def initialize(**attrs)
|
|
106
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class Expense
|
|
111
|
+
attr_accessor :id, :app_id, :contract_id, :license_id, :total, :date, :month, :year,
|
|
112
|
+
:description, :transaction_id, :vendor, :payment_channel, :currency,
|
|
113
|
+
:created_at, :updated_at
|
|
114
|
+
|
|
115
|
+
def initialize(**attrs)
|
|
116
|
+
@currency = "USD"
|
|
117
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class Contract
|
|
122
|
+
attr_accessor :id, :app_id, :title, :contract_type, :start_date, :end_date, :currency,
|
|
123
|
+
:cost, :cost_break_down, :cost_amortization, :add_ons, :add_ons_total_cost,
|
|
124
|
+
:total_contract_cost, :total_discount_amount, :total_original_cost,
|
|
125
|
+
:licenses, :expenses, :created_at, :updated_at
|
|
126
|
+
|
|
127
|
+
def initialize(**attrs)
|
|
128
|
+
@licenses = []
|
|
129
|
+
@expenses = []
|
|
130
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Metric
|
|
135
|
+
attr_accessor :id, :license_id, :metric_type, :usage_aggregation_type, :rate_kind,
|
|
136
|
+
:entitlement_period, :carryover_policy, :usage_rate, :usage_limit,
|
|
137
|
+
:usage_included, :per_unit_cap, :usage_rate_is_estimated,
|
|
138
|
+
:usage_custom_unit_label, :expected_emission_interval_minutes,
|
|
139
|
+
:created_at, :updated_at
|
|
140
|
+
|
|
141
|
+
def initialize(**attrs)
|
|
142
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class PaginatedResponse
|
|
147
|
+
attr_accessor :data, :cursor, :has_more
|
|
148
|
+
|
|
149
|
+
def initialize(**attrs)
|
|
150
|
+
attrs.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/doow_track.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "doow_track/version"
|
|
4
|
+
require_relative "doow_track/error"
|
|
5
|
+
require_relative "doow_track/types"
|
|
6
|
+
require_relative "doow_track/tracker"
|
|
7
|
+
require_relative "doow_track/management"
|
|
8
|
+
|
|
9
|
+
module DoowTrack
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :api_key, :endpoint, :debug
|
|
12
|
+
|
|
13
|
+
def configure
|
|
14
|
+
yield self
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
self.endpoint = "https://api.doow.co"
|
|
19
|
+
self.debug = false
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: doow_track
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Doow
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: json
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
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: net-http
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.3'
|
|
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.12'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.12'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webmock
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.18'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.18'
|
|
69
|
+
description: Track usage events and manage apps, contracts, licenses, and metrics
|
|
70
|
+
with the Doow API
|
|
71
|
+
email:
|
|
72
|
+
- dev@doow.co
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- lib/doow_track.rb
|
|
80
|
+
- lib/doow_track/error.rb
|
|
81
|
+
- lib/doow_track/management.rb
|
|
82
|
+
- lib/doow_track/tracker.rb
|
|
83
|
+
- lib/doow_track/types.rb
|
|
84
|
+
- lib/doow_track/version.rb
|
|
85
|
+
homepage: https://github.com/Doow-Dev/doow-track-ruby
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
homepage_uri: https://github.com/Doow-Dev/doow-track-ruby
|
|
90
|
+
source_code_uri: https://github.com/Doow-Dev/doow-track-ruby
|
|
91
|
+
changelog_uri: https://github.com/Doow-Dev/doow-track-ruby/blob/main/CHANGELOG.md
|
|
92
|
+
post_install_message:
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 3.0.0
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.4.19
|
|
108
|
+
signing_key:
|
|
109
|
+
specification_version: 4
|
|
110
|
+
summary: Official Ruby SDK for Doow usage telemetry and management
|
|
111
|
+
test_files: []
|