aptabase-ruby 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/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +185 -0
- data/lib/aptabase/client.rb +171 -0
- data/lib/aptabase/errors.rb +22 -0
- data/lib/aptabase/event.rb +28 -0
- data/lib/aptabase/railtie.rb +20 -0
- data/lib/aptabase/session.rb +33 -0
- data/lib/aptabase/system_properties.rb +45 -0
- data/lib/aptabase/transport.rb +42 -0
- data/lib/aptabase/version.rb +5 -0
- data/lib/aptabase-ruby.rb +5 -0
- data/lib/aptabase.rb +77 -0
- data/lib/generators/aptabase/install_generator.rb +20 -0
- data/lib/generators/aptabase/templates/aptabase.rb +21 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a789fcfdf15e96d57c8f3003265c50ab4f495636db7bba375e8187772c560460
|
|
4
|
+
data.tar.gz: e8729f5338cba96e112237c68b09d135575f29f354b8e5d3072177cd61a5d393
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6b797acc2b0b396ad410cf25f70daea999c2ad6bd37be07851b3df7e18b9ca62b3baebed4c903b800f3ea7b5657d09d30e2569be8cffa4ab0d90a98b172638af
|
|
7
|
+
data.tar.gz: 97c6b0dd5c9df7f186506ac1e6b5a76ad839abbd4a12d6579fb6fce08cc3a1af4ee2260d2e40b0ab976f702fb132cec5be3f224adef9e0396dafe35f16c822fd
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-11
|
|
8
|
+
|
|
9
|
+
Initial release, ported from [aptabase-python](https://github.com/aptabase/aptabase-python)
|
|
10
|
+
and verified against the live Aptabase ingestion API.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Aptabase.init` / `Aptabase.track` / `Aptabase.flush` / `Aptabase.stop` module-level singleton API
|
|
15
|
+
- `Aptabase::Client` instance API, including a block form that guarantees flush on exit
|
|
16
|
+
- Background worker thread with auto-batching (max 25 events) and periodic flushing (default 10s)
|
|
17
|
+
- Failed batches are re-queued and retried; network errors never raise from `track`
|
|
18
|
+
- Session tracking with a 1-hour inactivity timeout, using the id format shared by all Aptabase SDKs
|
|
19
|
+
- Fork-safety: the worker thread restarts automatically in forked Puma/Unicorn workers
|
|
20
|
+
- Pending events are flushed automatically at process exit
|
|
21
|
+
- EU/US region routing derived from the app key; self-hosted instances via `base_url:`
|
|
22
|
+
- Rails integration: railtie that defaults the SDK logger to `Rails.logger`
|
|
23
|
+
- `rails generate aptabase:install` generator for the initializer
|
|
24
|
+
- Runnable samples: a plain Ruby script and a single-file Rails app
|
|
25
|
+
- CI across Ruby 3.1-3.4 and Rails 7.1, 7.2, 8.0 and latest
|
|
26
|
+
|
|
27
|
+
[0.1.0]: https://github.com/tiny-cloud-ventures/aptabase-ruby/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tiny Cloud Ventures
|
|
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,185 @@
|
|
|
1
|
+
# Aptabase Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/aptabase-ruby)
|
|
4
|
+
[](https://github.com/tiny-cloud-ventures/aptabase-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Ruby and Rails SDK for [Aptabase](https://aptabase.com/) - open source, privacy-first
|
|
8
|
+
analytics for mobile, desktop and web applications.
|
|
9
|
+
|
|
10
|
+
> **Status:** 0.x - the API is stable and verified against the live Aptabase
|
|
11
|
+
> ingestion API, but minor releases may still include breaking changes until 1.0.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- 🧵 **Non-blocking** - `track` queues in memory; a background thread does the sending
|
|
16
|
+
- 🔒 **Privacy-first** - no personal data collection, no device identifiers
|
|
17
|
+
- 🔄 **Auto-batching** - events are batched (max 25) and flushed every 10 seconds
|
|
18
|
+
- ♻️ **Resilient** - failed batches are re-queued and retried; tracking never raises on network errors
|
|
19
|
+
- ⚡ **Zero dependencies** - stdlib only
|
|
20
|
+
- 🛤️ **Rails-friendly** - install generator, `Rails.logger` integration, fork-safe under Puma cluster mode, flushes on exit
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Ruby 3.1+
|
|
25
|
+
- Rails 7.1+ (optional - the SDK works in any Ruby program)
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add to your Gemfile:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "aptabase-ruby"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install directly:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gem install aptabase-ruby
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
require "aptabase"
|
|
45
|
+
|
|
46
|
+
Aptabase.init("A-EU-1234567890") # your app key
|
|
47
|
+
|
|
48
|
+
# Track a simple event
|
|
49
|
+
Aptabase.track("app_started")
|
|
50
|
+
|
|
51
|
+
# Track an event with properties
|
|
52
|
+
Aptabase.track("user_action", {
|
|
53
|
+
"action" => "button_click",
|
|
54
|
+
"button_id" => "login",
|
|
55
|
+
"screen" => "home"
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# Events are flushed automatically (and on process exit),
|
|
59
|
+
# but you can force it:
|
|
60
|
+
Aptabase.flush
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Rails
|
|
64
|
+
|
|
65
|
+
Add the gem, then generate the initializer:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
rails generate aptabase:install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This creates `config/initializers/aptabase.rb`, which reads the app key from
|
|
72
|
+
`Rails.application.credentials.dig(:aptabase, :app_key)` or `ENV["APTABASE_APP_KEY"]`.
|
|
73
|
+
|
|
74
|
+
Then call `Aptabase.track` anywhere - controllers, jobs, models:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class OrdersController < ApplicationController
|
|
78
|
+
def create
|
|
79
|
+
# ...
|
|
80
|
+
Aptabase.track("order_created", { "total_cents" => order.total_cents })
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The SDK logs through `Rails.logger` by default, restarts its background thread
|
|
86
|
+
automatically in forked Puma/Unicorn workers, and flushes pending events when
|
|
87
|
+
the process exits.
|
|
88
|
+
|
|
89
|
+
See [samples/rails-app](samples/rails-app) for a runnable single-file example.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Aptabase.init(
|
|
95
|
+
"A-EU-1234567890", # Your Aptabase app key
|
|
96
|
+
app_version: "1.2.3", # Your app's version, shown in the dashboard
|
|
97
|
+
is_debug: false, # Debug events are kept separate in Aptabase
|
|
98
|
+
max_batch_size: 25, # Max events per request (hard max 25)
|
|
99
|
+
flush_interval: 10.0, # Auto-flush interval in seconds
|
|
100
|
+
timeout: 30.0, # HTTP timeout in seconds
|
|
101
|
+
logger: Logger.new($stderr) # Where SDK warnings/debug logs go
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## App Key Format
|
|
106
|
+
|
|
107
|
+
Your app key determines the server region:
|
|
108
|
+
|
|
109
|
+
| Key prefix | Region |
|
|
110
|
+
| ---------- | ----------------------------------------------- |
|
|
111
|
+
| `A-EU-*` | European servers |
|
|
112
|
+
| `A-US-*` | US servers |
|
|
113
|
+
| `A-SH-*` | Self-hosted - requires the `base_url:` option |
|
|
114
|
+
|
|
115
|
+
Get your app key from the [Aptabase dashboard](https://aptabase.com/).
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# Self-hosted instance
|
|
119
|
+
Aptabase.init("A-SH-1234567890", base_url: "https://analytics.example.com")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Instance API
|
|
123
|
+
|
|
124
|
+
For multiple apps or explicit lifecycle control, use `Aptabase::Client` directly:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
client = Aptabase::Client.new("A-EU-1234567890", app_version: "1.0.0")
|
|
128
|
+
client.track("event")
|
|
129
|
+
client.flush # synchronous send
|
|
130
|
+
client.pending_count # events queued, not yet delivered
|
|
131
|
+
client.stop # flushes remaining events
|
|
132
|
+
|
|
133
|
+
# Block form - stop/flush guaranteed on exit
|
|
134
|
+
Aptabase::Client.start("A-EU-1234567890") do |client|
|
|
135
|
+
client.track("event")
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Sessions
|
|
140
|
+
|
|
141
|
+
Events are grouped into sessions automatically. A session id is reused until
|
|
142
|
+
one hour of inactivity, then rotated - the same semantics and id format as the
|
|
143
|
+
official Aptabase SDKs, so dashboards behave identically.
|
|
144
|
+
|
|
145
|
+
## Error Handling
|
|
146
|
+
|
|
147
|
+
Network failures never raise from `track` - failed batches are logged,
|
|
148
|
+
re-queued and retried on the next flush. Programmer errors do raise:
|
|
149
|
+
|
|
150
|
+
| Error | Raised when |
|
|
151
|
+
| ----------------------------- | -------------------------------------------- |
|
|
152
|
+
| `Aptabase::ConfigurationError`| bad app key or invalid options |
|
|
153
|
+
| `Aptabase::ValidationError` | bad event name or props |
|
|
154
|
+
| `Aptabase::NetworkError` | internal transport failures (`#status_code`) |
|
|
155
|
+
|
|
156
|
+
All inherit from `Aptabase::Error`.
|
|
157
|
+
|
|
158
|
+
## Samples
|
|
159
|
+
|
|
160
|
+
- [Simple script](samples/simple-script) - plain Ruby, no framework
|
|
161
|
+
- [Rails app](samples/rails-app) - single-file Rails app via `bundler/inline`
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
bundle install
|
|
167
|
+
bundle exec rspec # run tests
|
|
168
|
+
bundle exec rubocop # lint
|
|
169
|
+
|
|
170
|
+
# run the suite against a specific Rails version
|
|
171
|
+
BUNDLE_GEMFILE=gemfiles/rails_80.gemfile bundle install
|
|
172
|
+
BUNDLE_GEMFILE=gemfiles/rails_80.gemfile bundle exec rspec
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Contributing
|
|
176
|
+
|
|
177
|
+
Bug reports and pull requests are welcome at
|
|
178
|
+
[tiny-cloud-ventures/aptabase-ruby](https://github.com/tiny-cloud-ventures/aptabase-ruby/issues).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
[MIT](LICENSE)
|
|
183
|
+
|
|
184
|
+
This is a community SDK, ported from the official
|
|
185
|
+
[aptabase-python](https://github.com/aptabase/aptabase-python) SDK.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Aptabase
|
|
6
|
+
# Aptabase analytics client.
|
|
7
|
+
#
|
|
8
|
+
# `track` is synchronous and cheap: it validates the event and pushes it
|
|
9
|
+
# onto an in-memory queue. A background worker thread flushes the queue
|
|
10
|
+
# every `flush_interval` seconds, or as soon as `max_batch_size` events
|
|
11
|
+
# are pending. Failed batches are re-queued and retried on the next flush.
|
|
12
|
+
class Client
|
|
13
|
+
HOSTS = {
|
|
14
|
+
"EU" => "https://eu.aptabase.com",
|
|
15
|
+
"US" => "https://us.aptabase.com"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
MAX_BATCH_SIZE = 25
|
|
19
|
+
|
|
20
|
+
attr_reader :app_key, :base_url, :logger
|
|
21
|
+
|
|
22
|
+
# Block form: yields the client and guarantees stop/flush on exit.
|
|
23
|
+
#
|
|
24
|
+
# Aptabase::Client.start("A-EU-123") { |client| client.track("event") }
|
|
25
|
+
def self.start(app_key, **options)
|
|
26
|
+
client = new(app_key, **options)
|
|
27
|
+
return client unless block_given?
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
yield client
|
|
31
|
+
ensure
|
|
32
|
+
client.stop
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(app_key, app_version: "1.0.0", is_debug: false,
|
|
37
|
+
max_batch_size: MAX_BATCH_SIZE, flush_interval: 10.0,
|
|
38
|
+
timeout: 30.0, base_url: nil, logger: nil)
|
|
39
|
+
validate_app_key!(app_key)
|
|
40
|
+
raise ConfigurationError, "Maximum batch size is #{MAX_BATCH_SIZE} events" if max_batch_size > MAX_BATCH_SIZE
|
|
41
|
+
raise ConfigurationError, "max_batch_size must be at least 1" if max_batch_size < 1
|
|
42
|
+
|
|
43
|
+
@app_key = app_key
|
|
44
|
+
@base_url = base_url || default_base_url(app_key)
|
|
45
|
+
@system_props = SystemProperties.new(app_version: app_version, is_debug: is_debug)
|
|
46
|
+
@max_batch_size = max_batch_size
|
|
47
|
+
@flush_interval = flush_interval
|
|
48
|
+
@logger = logger || Logger.new($stderr, progname: "aptabase", level: Logger::INFO)
|
|
49
|
+
@transport = Transport.new(base_url: @base_url, app_key: app_key, timeout: timeout)
|
|
50
|
+
@session = Session.new
|
|
51
|
+
|
|
52
|
+
@queue = []
|
|
53
|
+
@queue_mutex = Mutex.new
|
|
54
|
+
@flush_mutex = Mutex.new
|
|
55
|
+
@wake = ConditionVariable.new
|
|
56
|
+
@worker = nil
|
|
57
|
+
@stopping = false
|
|
58
|
+
@pid = Process.pid
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Track an analytics event.
|
|
62
|
+
#
|
|
63
|
+
# client.track("app_started")
|
|
64
|
+
# client.track("purchase", { "product_id" => "abc", "price" => 29.99 })
|
|
65
|
+
def track(event_name, props = nil)
|
|
66
|
+
unless event_name.is_a?(String) && !event_name.empty?
|
|
67
|
+
raise ValidationError, "Event name is required and must be a non-empty string"
|
|
68
|
+
end
|
|
69
|
+
raise ValidationError, "Event properties must be a Hash" if !props.nil? && !props.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
event = Event.new(name: event_name, session_id: @session.id, props: props)
|
|
72
|
+
|
|
73
|
+
@queue_mutex.synchronize do
|
|
74
|
+
ensure_worker
|
|
75
|
+
@queue << event
|
|
76
|
+
@wake.signal if @queue.size >= @max_batch_size
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Synchronously send all queued events in batches of `max_batch_size`.
|
|
82
|
+
# On failure the unsent events stay queued for the next flush.
|
|
83
|
+
def flush
|
|
84
|
+
@flush_mutex.synchronize do
|
|
85
|
+
loop do
|
|
86
|
+
batch = @queue_mutex.synchronize { @queue.shift(@max_batch_size) }
|
|
87
|
+
break if batch.empty?
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
@transport.post_events(batch.map { |event| event.to_h(@system_props) })
|
|
91
|
+
logger.debug { "[aptabase] sent #{batch.size} event(s)" }
|
|
92
|
+
rescue NetworkError => e
|
|
93
|
+
logger.warn("[aptabase] failed to send #{batch.size} event(s), will retry: #{e.message}")
|
|
94
|
+
@queue_mutex.synchronize { @queue.unshift(*batch) }
|
|
95
|
+
break
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Stop the background worker and flush any remaining events.
|
|
103
|
+
# The client can keep being used afterwards; tracking restarts the worker.
|
|
104
|
+
def stop
|
|
105
|
+
worker = nil
|
|
106
|
+
@queue_mutex.synchronize do
|
|
107
|
+
@stopping = true
|
|
108
|
+
worker = @worker
|
|
109
|
+
@wake.broadcast
|
|
110
|
+
end
|
|
111
|
+
worker.join(@flush_interval + 1) if worker && worker != Thread.current
|
|
112
|
+
flush
|
|
113
|
+
@queue_mutex.synchronize do
|
|
114
|
+
@worker = nil
|
|
115
|
+
@stopping = false
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Number of events queued and not yet delivered.
|
|
121
|
+
def pending_count
|
|
122
|
+
@queue_mutex.synchronize { @queue.size }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def validate_app_key!(app_key)
|
|
128
|
+
unless app_key.is_a?(String) && !app_key.empty?
|
|
129
|
+
raise ConfigurationError, "App key is required and must be a string"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parts = app_key.split("-")
|
|
133
|
+
return if parts.length == 3 && parts[0] == "A" && (HOSTS.key?(parts[1]) || parts[1] == "SH")
|
|
134
|
+
|
|
135
|
+
raise ConfigurationError, "Invalid app key format. Expected format: A-{REGION}-{ID}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def default_base_url(app_key)
|
|
139
|
+
region = app_key.split("-")[1]
|
|
140
|
+
raise ConfigurationError, "Self-hosted app keys (A-SH-*) require the base_url option" if region == "SH"
|
|
141
|
+
|
|
142
|
+
HOSTS.fetch(region)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Must be called with @queue_mutex held.
|
|
146
|
+
def ensure_worker
|
|
147
|
+
if @pid != Process.pid
|
|
148
|
+
# Forked child: the parent's worker thread does not survive fork.
|
|
149
|
+
@pid = Process.pid
|
|
150
|
+
@worker = nil
|
|
151
|
+
@stopping = false
|
|
152
|
+
end
|
|
153
|
+
return if @stopping || @worker&.alive?
|
|
154
|
+
|
|
155
|
+
@worker = Thread.new { worker_loop }
|
|
156
|
+
@worker.name = "aptabase-flusher"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def worker_loop
|
|
160
|
+
loop do
|
|
161
|
+
stopping = false
|
|
162
|
+
@queue_mutex.synchronize do
|
|
163
|
+
@wake.wait(@queue_mutex, @flush_interval) unless @stopping || @queue.size >= @max_batch_size
|
|
164
|
+
stopping = @stopping
|
|
165
|
+
end
|
|
166
|
+
flush
|
|
167
|
+
break if stopping
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aptabase
|
|
4
|
+
# Base class for all errors raised by the SDK.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the SDK is misconfigured (bad app key, invalid options).
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when event data fails validation.
|
|
11
|
+
class ValidationError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a request to the Aptabase API fails.
|
|
14
|
+
class NetworkError < Error
|
|
15
|
+
attr_reader :status_code
|
|
16
|
+
|
|
17
|
+
def initialize(message, status_code: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Aptabase
|
|
7
|
+
# An analytics event queued for delivery to Aptabase.
|
|
8
|
+
class Event
|
|
9
|
+
attr_reader :name, :timestamp, :session_id, :props
|
|
10
|
+
|
|
11
|
+
def initialize(name:, timestamp: nil, session_id: nil, props: nil)
|
|
12
|
+
@name = name
|
|
13
|
+
@timestamp = (timestamp || Time.now).utc
|
|
14
|
+
@session_id = session_id || SecureRandom.uuid
|
|
15
|
+
@props = props || {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h(system_props)
|
|
19
|
+
{
|
|
20
|
+
"timestamp" => timestamp.iso8601(3),
|
|
21
|
+
"sessionId" => session_id,
|
|
22
|
+
"eventName" => name,
|
|
23
|
+
"systemProps" => system_props.to_h,
|
|
24
|
+
"props" => props
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Aptabase
|
|
6
|
+
# Rails integration. Kept deliberately thin: configuration happens
|
|
7
|
+
# explicitly in an initializer (see `rails g aptabase:install`); the
|
|
8
|
+
# railtie only wires the SDK's default logger to Rails.logger.
|
|
9
|
+
#
|
|
10
|
+
# Process-exit flushing and Puma/Unicorn fork-safety are handled by the
|
|
11
|
+
# core SDK and need no Rails-specific hooks.
|
|
12
|
+
class Railtie < ::Rails::Railtie
|
|
13
|
+
# after_initialize, not an initializer hook: Rails may wrap or replace
|
|
14
|
+
# Rails.logger during boot (e.g. with BroadcastLogger), so capture the
|
|
15
|
+
# final object once the app is fully initialized.
|
|
16
|
+
config.after_initialize do
|
|
17
|
+
Aptabase.default_logger ||= ::Rails.logger
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aptabase
|
|
4
|
+
# Session tracking with the same semantics as the other Aptabase SDKs:
|
|
5
|
+
# a session id is reused until one hour of inactivity, then rotated.
|
|
6
|
+
class Session
|
|
7
|
+
TIMEOUT = 60 * 60 # seconds
|
|
8
|
+
|
|
9
|
+
def initialize(clock: -> { Time.now })
|
|
10
|
+
@clock = clock
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@id = nil
|
|
13
|
+
@last_touched = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def id
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
now = @clock.call
|
|
19
|
+
@id = generate_id(now) if @id.nil? || now - @last_touched > TIMEOUT
|
|
20
|
+
@last_touched = now
|
|
21
|
+
@id
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Same id format as the other Aptabase SDKs:
|
|
28
|
+
# epoch seconds * 1e8 plus an 8-digit random part.
|
|
29
|
+
def generate_id(now)
|
|
30
|
+
((now.to_i * 100_000_000) + rand(100_000_000)).to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Aptabase
|
|
6
|
+
# System properties automatically collected by the SDK and attached to
|
|
7
|
+
# every event. Mirrors the fields sent by the other Aptabase SDKs.
|
|
8
|
+
class SystemProperties
|
|
9
|
+
attr_reader :locale, :os_name, :os_version, :device_model,
|
|
10
|
+
:is_debug, :app_version, :sdk_version
|
|
11
|
+
|
|
12
|
+
def initialize(app_version: "1.0.0", is_debug: false)
|
|
13
|
+
uname = Etc.uname
|
|
14
|
+
@locale = detect_locale
|
|
15
|
+
@os_name = uname[:sysname]
|
|
16
|
+
@os_version = uname[:release]
|
|
17
|
+
@device_model = uname[:machine]
|
|
18
|
+
@is_debug = is_debug
|
|
19
|
+
@app_version = app_version
|
|
20
|
+
@sdk_version = "aptabase-ruby@#{VERSION}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
"locale" => locale,
|
|
26
|
+
"osName" => os_name,
|
|
27
|
+
"osVersion" => os_version,
|
|
28
|
+
"deviceModel" => device_model,
|
|
29
|
+
"isDebug" => is_debug,
|
|
30
|
+
"appVersion" => app_version,
|
|
31
|
+
"sdkVersion" => sdk_version
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Turns POSIX locale strings like "en_US.UTF-8" into BCP 47-ish "en-US".
|
|
38
|
+
def detect_locale
|
|
39
|
+
raw = ENV["LC_ALL"] || ENV["LC_MESSAGES"] || ENV.fetch("LANG", nil)
|
|
40
|
+
return "en-US" if raw.nil? || raw.empty? || %w[C POSIX].include?(raw)
|
|
41
|
+
|
|
42
|
+
raw.split(".").first.tr("_", "-")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Aptabase
|
|
9
|
+
# Thin HTTP layer over Net::HTTP. Raises NetworkError for any failure so
|
|
10
|
+
# the client only has one error type to handle.
|
|
11
|
+
class Transport
|
|
12
|
+
EVENTS_PATH = "/api/v0/events"
|
|
13
|
+
|
|
14
|
+
def initialize(base_url:, app_key:, timeout: 30.0)
|
|
15
|
+
@uri = URI.parse(base_url.chomp("/") + EVENTS_PATH)
|
|
16
|
+
@app_key = app_key
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post_events(payload)
|
|
21
|
+
request = Net::HTTP::Post.new(@uri)
|
|
22
|
+
request["App-Key"] = @app_key
|
|
23
|
+
request["Content-Type"] = "application/json"
|
|
24
|
+
request.body = JSON.generate(payload)
|
|
25
|
+
|
|
26
|
+
response = Net::HTTP.start(
|
|
27
|
+
@uri.host, @uri.port,
|
|
28
|
+
use_ssl: @uri.scheme == "https",
|
|
29
|
+
open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout
|
|
30
|
+
) { |http| http.request(request) }
|
|
31
|
+
|
|
32
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
33
|
+
|
|
34
|
+
raise NetworkError.new(
|
|
35
|
+
"HTTP error #{response.code}: #{response.body}",
|
|
36
|
+
status_code: response.code.to_i
|
|
37
|
+
)
|
|
38
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError => e
|
|
39
|
+
raise NetworkError, "Network error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/aptabase.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "aptabase/version"
|
|
4
|
+
require_relative "aptabase/errors"
|
|
5
|
+
require_relative "aptabase/system_properties"
|
|
6
|
+
require_relative "aptabase/event"
|
|
7
|
+
require_relative "aptabase/session"
|
|
8
|
+
require_relative "aptabase/transport"
|
|
9
|
+
require_relative "aptabase/client"
|
|
10
|
+
|
|
11
|
+
# Module-level singleton API, the most common way to use the SDK:
|
|
12
|
+
#
|
|
13
|
+
# Aptabase.init("A-EU-1234567890", app_version: "1.2.3")
|
|
14
|
+
# Aptabase.track("app_started")
|
|
15
|
+
# Aptabase.track("purchase", { "product_id" => "abc123" })
|
|
16
|
+
#
|
|
17
|
+
# For multiple apps or explicit lifecycle control, use Aptabase::Client directly.
|
|
18
|
+
module Aptabase
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :client
|
|
21
|
+
|
|
22
|
+
# Logger used when `init` is not given an explicit one. Set to
|
|
23
|
+
# Rails.logger automatically by the railtie in Rails apps.
|
|
24
|
+
attr_accessor :default_logger
|
|
25
|
+
|
|
26
|
+
# Initialize the global client. Replaces (and stops) any previous one.
|
|
27
|
+
# Queued events are flushed automatically when the process exits.
|
|
28
|
+
def init(app_key, **options)
|
|
29
|
+
@client&.stop
|
|
30
|
+
options[:logger] = default_logger if default_logger && !options.key?(:logger)
|
|
31
|
+
@client = Client.new(app_key, **options)
|
|
32
|
+
register_at_exit
|
|
33
|
+
@client
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Track an event on the global client. Warns (once) and discards the
|
|
37
|
+
# event if `Aptabase.init` has not been called.
|
|
38
|
+
def track(event_name, props = nil)
|
|
39
|
+
client = @client
|
|
40
|
+
if client.nil?
|
|
41
|
+
warn_not_initialized
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
client.track(event_name, props)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Synchronously send all queued events.
|
|
48
|
+
def flush
|
|
49
|
+
@client&.flush
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stop the global client, flushing any remaining events.
|
|
54
|
+
def stop
|
|
55
|
+
@client&.stop
|
|
56
|
+
@client = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def register_at_exit
|
|
62
|
+
return if @at_exit_registered
|
|
63
|
+
|
|
64
|
+
@at_exit_registered = true
|
|
65
|
+
at_exit { @client&.stop }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def warn_not_initialized
|
|
69
|
+
return if @warned_not_initialized
|
|
70
|
+
|
|
71
|
+
@warned_not_initialized = true
|
|
72
|
+
Kernel.warn("[aptabase] Aptabase.track called before Aptabase.init - event discarded")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
require_relative "aptabase/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Aptabase
|
|
6
|
+
module Generators
|
|
7
|
+
# Creates config/initializers/aptabase.rb.
|
|
8
|
+
#
|
|
9
|
+
# rails generate aptabase:install
|
|
10
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates an Aptabase initializer in config/initializers"
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
copy_file "aptabase.rb", "config/initializers/aptabase.rb"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Aptabase - privacy-first analytics (https://aptabase.com)
|
|
4
|
+
#
|
|
5
|
+
# Get your app key from the Aptabase dashboard and store it in
|
|
6
|
+
# credentials (bin/rails credentials:edit):
|
|
7
|
+
#
|
|
8
|
+
# aptabase:
|
|
9
|
+
# app_key: A-EU-xxxxxxxxxx
|
|
10
|
+
#
|
|
11
|
+
# or in the APTABASE_APP_KEY environment variable.
|
|
12
|
+
app_key = Rails.application.credentials.dig(:aptabase, :app_key) || ENV.fetch("APTABASE_APP_KEY", nil)
|
|
13
|
+
|
|
14
|
+
if app_key
|
|
15
|
+
Aptabase.init(
|
|
16
|
+
app_key,
|
|
17
|
+
app_version: ENV.fetch("APP_VERSION", "1.0.0"),
|
|
18
|
+
is_debug: !Rails.env.production?
|
|
19
|
+
# base_url: "https://analytics.example.com" # required for self-hosted (A-SH-*) keys
|
|
20
|
+
)
|
|
21
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: aptabase-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tiny Cloud Ventures
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: logger
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Ruby and Rails SDK for Aptabase, privacy-first analytics for mobile,
|
|
27
|
+
desktop and web applications.
|
|
28
|
+
email:
|
|
29
|
+
- matthew@tinycloudventures.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/aptabase-ruby.rb
|
|
38
|
+
- lib/aptabase.rb
|
|
39
|
+
- lib/aptabase/client.rb
|
|
40
|
+
- lib/aptabase/errors.rb
|
|
41
|
+
- lib/aptabase/event.rb
|
|
42
|
+
- lib/aptabase/railtie.rb
|
|
43
|
+
- lib/aptabase/session.rb
|
|
44
|
+
- lib/aptabase/system_properties.rb
|
|
45
|
+
- lib/aptabase/transport.rb
|
|
46
|
+
- lib/aptabase/version.rb
|
|
47
|
+
- lib/generators/aptabase/install_generator.rb
|
|
48
|
+
- lib/generators/aptabase/templates/aptabase.rb
|
|
49
|
+
homepage: https://github.com/tiny-cloud-ventures/aptabase-ruby
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/tiny-cloud-ventures/aptabase-ruby
|
|
54
|
+
source_code_uri: https://github.com/tiny-cloud-ventures/aptabase-ruby
|
|
55
|
+
changelog_uri: https://github.com/tiny-cloud-ventures/aptabase-ruby/blob/main/CHANGELOG.md
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.1.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.6
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Ruby SDK for Aptabase - privacy-first analytics
|
|
74
|
+
test_files: []
|