tgvizor 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 +115 -0
- data/lib/tgvizor/client.rb +230 -0
- data/lib/tgvizor/disk_queue.rb +88 -0
- data/lib/tgvizor/event_queue.rb +57 -0
- data/lib/tgvizor/middleware/telegram_bot_ruby.rb +235 -0
- data/lib/tgvizor/transport.rb +144 -0
- data/lib/tgvizor/version.rb +5 -0
- data/lib/tgvizor.rb +17 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 31681be1460576d09d0dea7a7315b6d7ff812a9ac425e6ae62c8c5bd4d5cb717
|
|
4
|
+
data.tar.gz: a934ae3c134704255c2a8ea2fb71db2f26b0493a3a89bb03f0b5ba62bdd77b30
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 254f3e78dcc28a652740274cca131deb4e07615c88c8168bff72446dbbdfc74b9eb0722a965234705df5835eb04dc703bea11c41ab3d65283bd6187af650d74f
|
|
7
|
+
data.tar.gz: 89bc809693738c7987af924f36e48672dc0eeae65c9aa4526a39d8bd2cbe33d1427d11daea6f8630a3fcb7dd1f7f8483fbb60b84761687f8623f57b12b7bedb7
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TGVizor
|
|
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,115 @@
|
|
|
1
|
+
# tgvizor
|
|
2
|
+
|
|
3
|
+
**Analytics SDK for Telegram bots.** Track events, errors, performance, user journeys, and blocked users with one line of middleware. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install tgvizor
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Ruby >= 3.1.
|
|
10
|
+
|
|
11
|
+
## telegram-bot-ruby
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require 'telegram/bot'
|
|
15
|
+
require 'tgvizor'
|
|
16
|
+
require 'tgvizor/middleware/telegram_bot_ruby'
|
|
17
|
+
|
|
18
|
+
vizor = TgVizor::Client.new(api_key: ENV['TGVIZOR_API_KEY'])
|
|
19
|
+
tracker = TgVizor::Middleware::TelegramBotRuby.new(vizor)
|
|
20
|
+
|
|
21
|
+
Telegram::Bot::Client.run(ENV['BOT_TOKEN']) do |bot|
|
|
22
|
+
bot.listen do |update|
|
|
23
|
+
tracker.track(update) do
|
|
24
|
+
# your existing handler — unchanged
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What gets tracked automatically
|
|
31
|
+
|
|
32
|
+
- **Commands** (`/start`, `/help`, etc., with `@BotName` stripped). The full argument string is stored in `properties[:args]`.
|
|
33
|
+
- **Messages** by type (text, photo, voice, sticker, video, document, audio, animation, video_note, location, contact, poll, dice). The **plain text content** of text messages is NOT captured by default — see "Capturing message text" below.
|
|
34
|
+
- **Callback queries** (button presses) — `data` is stored verbatim.
|
|
35
|
+
- **Inline queries** — `query` is stored verbatim.
|
|
36
|
+
- **Response time** — attached to every action event as `_response_time_ms` + `_response_time_handler`
|
|
37
|
+
- **First-seen users** — `$identify` with username, first_name, language_code, is_premium (bounded 50k LRU)
|
|
38
|
+
- **Errors** — any raised `StandardError` becomes a `$error` event with fingerprint
|
|
39
|
+
- **Blocked users** — 403 from Telegram becomes a `user_blocked` event with `last_action`
|
|
40
|
+
|
|
41
|
+
The middleware re-raises every exception so your existing error handling stays intact.
|
|
42
|
+
|
|
43
|
+
## Capturing message text
|
|
44
|
+
|
|
45
|
+
By default, the middleware stores only the **type** of plain (non-command) messages, not the content. This is the privacy-safe default — plain messages can contain anything (emails, card numbers, personal info), and storing user-generated content has GDPR implications you'll want to opt into explicitly.
|
|
46
|
+
|
|
47
|
+
For bots where the message content IS the signal — AI chatbots, translators, URL extractors, search bots — enable it explicitly:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
tracker = TgVizor::Middleware::TelegramBotRuby.new(
|
|
51
|
+
vizor,
|
|
52
|
+
capture_message_text: true, # default false
|
|
53
|
+
max_message_text_length: 500, # default 500; longer is truncated with "…"
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Captured text lands in `properties[:text]` on the `message` event and is visible in the Event Explorer + User Journey pages. Commands always capture `args` regardless of this setting — those are structured arguments you designed.
|
|
58
|
+
|
|
59
|
+
## Custom events
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
vizor.track('purchase', user_id: update.from.id, properties: { amount: 9.99 })
|
|
63
|
+
|
|
64
|
+
vizor.identify(update.from.id, username: update.from.username, language_code: update.from.language_code)
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
handle_checkout
|
|
68
|
+
rescue => e
|
|
69
|
+
vizor.capture_error(e, user_id: update.from.id, command: '/checkout', extra: { cart: 3 })
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Graceful shutdown
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
at_exit do
|
|
78
|
+
vizor.shutdown!
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
(The SDK registers this automatically; listed here for reference.)
|
|
83
|
+
|
|
84
|
+
## Disk fallback
|
|
85
|
+
|
|
86
|
+
If the ingestion API is unreachable after `max_retries` attempts, events persist to `.tgvizor/events.jsonl` (capped at 10 MB) and drain automatically when connectivity returns. Disable with `persist_queue: false`.
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
TgVizor::Client.new(
|
|
92
|
+
api_key: 'pk_live_xxx', # required
|
|
93
|
+
|
|
94
|
+
endpoint: 'https://ingest.tgvizor.com',
|
|
95
|
+
flush_interval: 5, # seconds
|
|
96
|
+
max_queue_size: 10_000,
|
|
97
|
+
max_retries: 5,
|
|
98
|
+
persist_queue: true,
|
|
99
|
+
batch_size: 500,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## HTTP fallback
|
|
104
|
+
|
|
105
|
+
No Ruby? Any language can POST to `https://ingest.tgvizor.com/v1/events` directly. See [api-reference.md](https://github.com/LinGG/tgvizor/blob/main/docs/api-reference.md).
|
|
106
|
+
|
|
107
|
+
## Links
|
|
108
|
+
|
|
109
|
+
- 📖 [Full documentation](https://github.com/LinGG/tgvizor/tree/main/docs)
|
|
110
|
+
- 🏠 [tgvizor.com](https://tgvizor.com)
|
|
111
|
+
- 🐛 [Issues](https://github.com/LinGG/tgvizor/issues)
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "thread"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module TgVizor
|
|
8
|
+
# TgVizor analytics client.
|
|
9
|
+
#
|
|
10
|
+
# Buffers events in memory, flushes them in the background to the ingestion API,
|
|
11
|
+
# and falls back to a JSONL disk queue if the API is unreachable.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# vizor = TgVizor::Client.new(api_key: ENV["TGVIZOR_API_KEY"])
|
|
15
|
+
# vizor.track("purchase", user_id: 123, properties: { amount: 9.99 })
|
|
16
|
+
# vizor.identify(123, username: "john")
|
|
17
|
+
#
|
|
18
|
+
# begin
|
|
19
|
+
# do_work
|
|
20
|
+
# rescue => e
|
|
21
|
+
# vizor.capture_error(e, user_id: 123, command: "/buy")
|
|
22
|
+
# raise
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# vizor.shutdown! # also auto-called via at_exit
|
|
26
|
+
class Client
|
|
27
|
+
DEFAULT_ENDPOINT = "https://ingest.tgvizor.com"
|
|
28
|
+
DEFAULT_FLUSH_INTERVAL = 5 # seconds
|
|
29
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
30
|
+
DEFAULT_BATCH_SIZE = 500
|
|
31
|
+
DEFAULT_MAX_RETRIES = 5
|
|
32
|
+
|
|
33
|
+
def initialize(
|
|
34
|
+
api_key:,
|
|
35
|
+
endpoint: DEFAULT_ENDPOINT,
|
|
36
|
+
flush_interval: DEFAULT_FLUSH_INTERVAL,
|
|
37
|
+
max_queue_size: DEFAULT_MAX_QUEUE_SIZE,
|
|
38
|
+
batch_size: DEFAULT_BATCH_SIZE,
|
|
39
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
40
|
+
persist_queue: true,
|
|
41
|
+
auto_shutdown: true,
|
|
42
|
+
debug: false,
|
|
43
|
+
logger: nil
|
|
44
|
+
)
|
|
45
|
+
raise ArgumentError, "TgVizor: api_key is required" if api_key.nil? || api_key.to_s.empty?
|
|
46
|
+
|
|
47
|
+
@api_key = api_key
|
|
48
|
+
@endpoint = endpoint
|
|
49
|
+
@flush_interval = flush_interval
|
|
50
|
+
@batch_size = batch_size
|
|
51
|
+
@debug = debug
|
|
52
|
+
@logger = logger
|
|
53
|
+
|
|
54
|
+
@queue = EventQueue.new(max_queue_size)
|
|
55
|
+
@transport = Transport.new(
|
|
56
|
+
endpoint: endpoint,
|
|
57
|
+
api_key: api_key,
|
|
58
|
+
max_retries: max_retries,
|
|
59
|
+
debug: debug,
|
|
60
|
+
logger: logger,
|
|
61
|
+
)
|
|
62
|
+
@disk_queue = persist_queue ? DiskQueue.new : nil
|
|
63
|
+
|
|
64
|
+
@shutdown = false
|
|
65
|
+
@state_mutex = Mutex.new
|
|
66
|
+
@flush_mutex = Mutex.new
|
|
67
|
+
@flush_thread = nil
|
|
68
|
+
|
|
69
|
+
start_flush_loop
|
|
70
|
+
install_at_exit_hook if auto_shutdown
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Track a custom event.
|
|
74
|
+
def track(event_name, user_id: nil, properties: nil, timestamp: nil)
|
|
75
|
+
enqueue(
|
|
76
|
+
event: event_name,
|
|
77
|
+
userId: user_id,
|
|
78
|
+
properties: properties,
|
|
79
|
+
timestamp: timestamp,
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Identify a user with optional traits (profile enrichment).
|
|
84
|
+
def identify(user_id, traits = {})
|
|
85
|
+
enqueue(
|
|
86
|
+
event: "$identify",
|
|
87
|
+
userId: user_id,
|
|
88
|
+
properties: traits,
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Capture an exception. Includes class name, message, fingerprint and
|
|
93
|
+
# the first stack frame so the dashboard can group it.
|
|
94
|
+
def capture_error(error, user_id: nil, command: nil, extra: nil)
|
|
95
|
+
stack = error.backtrace || []
|
|
96
|
+
first_frame = stack.first.to_s
|
|
97
|
+
fingerprint = build_fingerprint(error.class.name.to_s, error.message.to_s, first_frame)
|
|
98
|
+
|
|
99
|
+
properties = {
|
|
100
|
+
type: error.class.name,
|
|
101
|
+
message: error.message,
|
|
102
|
+
stack: stack.first(50).join("\n"),
|
|
103
|
+
fingerprint: fingerprint,
|
|
104
|
+
}
|
|
105
|
+
properties[:context] = { user_id: user_id, command: command, extra: extra }.compact unless user_id.nil? && command.nil? && extra.nil?
|
|
106
|
+
|
|
107
|
+
enqueue(event: "$error", userId: user_id, properties: properties)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Drain the in-memory + disk queues to the ingestion API. Safe to call
|
|
111
|
+
# at any time. Skips silently if there's nothing to send.
|
|
112
|
+
def flush!
|
|
113
|
+
@flush_mutex.synchronize do
|
|
114
|
+
# Pull anything previously persisted to disk back into memory first.
|
|
115
|
+
# `any?` is an O(1) stat — `size` would re-read the whole JSONL file.
|
|
116
|
+
if @disk_queue && @disk_queue.any?
|
|
117
|
+
@disk_queue.drain_all.each { |e| @queue.push(e) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
while @queue.any?
|
|
121
|
+
batch = @queue.drain(@batch_size)
|
|
122
|
+
break if batch.empty?
|
|
123
|
+
|
|
124
|
+
result = @transport.send_batch(batch)
|
|
125
|
+
|
|
126
|
+
unless result.ok?
|
|
127
|
+
if result.retryable && @disk_queue
|
|
128
|
+
batch.each { |e| @disk_queue.append(e) }
|
|
129
|
+
end
|
|
130
|
+
debug_log("flush failed: #{result.message}")
|
|
131
|
+
break
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Stop the background flush thread and drain remaining events. Idempotent.
|
|
138
|
+
def shutdown!
|
|
139
|
+
@state_mutex.synchronize do
|
|
140
|
+
return if @shutdown
|
|
141
|
+
|
|
142
|
+
@shutdown = true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
stop_flush_loop
|
|
146
|
+
flush!
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def shutdown?
|
|
150
|
+
@state_mutex.synchronize { @shutdown }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [Integer] events currently buffered in memory
|
|
154
|
+
def queue_size
|
|
155
|
+
@queue.size
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def enqueue(event)
|
|
161
|
+
if shutdown?
|
|
162
|
+
debug_log("dropping event, SDK is shut down")
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
queued = event.compact.merge(
|
|
167
|
+
timestamp: event[:timestamp] || (Time.now.utc.to_f * 1000).to_i,
|
|
168
|
+
_sdk_version: VERSION,
|
|
169
|
+
_sdk_lang: "ruby",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
overflowed = @queue.push(queued)
|
|
173
|
+
debug_log("queue overflow, oldest event dropped") if overflowed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def start_flush_loop
|
|
177
|
+
@flush_thread = Thread.new do
|
|
178
|
+
loop do
|
|
179
|
+
sleep @flush_interval
|
|
180
|
+
break if shutdown?
|
|
181
|
+
next unless @queue.any?
|
|
182
|
+
|
|
183
|
+
begin
|
|
184
|
+
flush!
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
debug_log("flush loop error: #{e.class} #{e.message}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
@flush_thread.name = "tgvizor-flush"
|
|
191
|
+
@flush_thread.report_on_exception = false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def stop_flush_loop
|
|
195
|
+
thread = @flush_thread
|
|
196
|
+
return unless thread
|
|
197
|
+
|
|
198
|
+
# Wake the thread out of its sleep so it notices @state and exits cleanly.
|
|
199
|
+
thread.wakeup if thread.alive?
|
|
200
|
+
thread.join(2) # cap at 2s — we'll force-kill if it doesn't cooperate
|
|
201
|
+
thread.kill if thread.alive?
|
|
202
|
+
@flush_thread = nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def install_at_exit_hook
|
|
206
|
+
# Use a weak reference via a closure that re-checks state, so multiple
|
|
207
|
+
# Client instances each register their own hook.
|
|
208
|
+
client_ref = self
|
|
209
|
+
at_exit { client_ref.shutdown! }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Same shape as the TS SDK fingerprint: "fp_" + base36 of a hash.
|
|
213
|
+
# We use SHA256 truncated rather than the TS hash function — same column,
|
|
214
|
+
# different language, that's fine since the dashboard groups by exact match.
|
|
215
|
+
def build_fingerprint(type, message, first_frame)
|
|
216
|
+
digest = Digest::SHA256.hexdigest("#{type}:#{message}:#{first_frame}")
|
|
217
|
+
"fp_#{digest[0, 16]}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def debug_log(msg)
|
|
221
|
+
return unless @debug
|
|
222
|
+
|
|
223
|
+
if @logger
|
|
224
|
+
@logger.debug("[tgvizor] #{msg}")
|
|
225
|
+
else
|
|
226
|
+
warn("[tgvizor] #{msg}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module TgVizor
|
|
7
|
+
# JSONL-based disk queue for persisting events when the ingestion API is unreachable.
|
|
8
|
+
#
|
|
9
|
+
# Append-only writes during normal operation. On drain, reads the whole file
|
|
10
|
+
# and truncates. Corrupt lines (e.g. from a crash mid-write) are silently
|
|
11
|
+
# skipped — the SDK must never crash the host bot.
|
|
12
|
+
#
|
|
13
|
+
# Capped at 10 MB to bound disk usage during prolonged outages; further events
|
|
14
|
+
# are silently dropped once the cap is reached.
|
|
15
|
+
class DiskQueue
|
|
16
|
+
DEFAULT_PATH = File.join(".tgvizor", "events.jsonl")
|
|
17
|
+
MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
18
|
+
|
|
19
|
+
def initialize(path: nil)
|
|
20
|
+
@path = path || File.join(Dir.pwd, DEFAULT_PATH)
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Append a single event. Silently no-ops if the cap is reached or the
|
|
25
|
+
# write fails — observability code must not crash the host bot.
|
|
26
|
+
def append(event)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return if file_size >= MAX_FILE_BYTES
|
|
29
|
+
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
31
|
+
File.open(@path, "a") do |f|
|
|
32
|
+
f.write(JSON.generate(event))
|
|
33
|
+
f.write("\n")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# disk write failed — drop the event rather than raise
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Read every event off disk, truncate the file, and return them.
|
|
41
|
+
# Corrupt lines are skipped.
|
|
42
|
+
def drain_all
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
return [] unless File.exist?(@path)
|
|
45
|
+
|
|
46
|
+
content = File.read(@path)
|
|
47
|
+
File.write(@path, "") # truncate immediately so concurrent drains don't double-read
|
|
48
|
+
|
|
49
|
+
content.each_line.filter_map do |line|
|
|
50
|
+
stripped = line.strip
|
|
51
|
+
next if stripped.empty?
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
JSON.parse(stripped, symbolize_names: true)
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def file_size
|
|
65
|
+
File.exist?(@path) ? File.size(@path) : 0
|
|
66
|
+
rescue StandardError
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# O(1) check — used by the flush loop to skip empty disk queues without
|
|
71
|
+
# paying the file-scan cost of `size`.
|
|
72
|
+
def any?
|
|
73
|
+
file_size.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Approximate number of events currently on disk. Walks the file — only
|
|
77
|
+
# call this when you actually need the count, never as a hot-path guard.
|
|
78
|
+
def size
|
|
79
|
+
return 0 unless File.exist?(@path)
|
|
80
|
+
|
|
81
|
+
File.foreach(@path).count { |l| !l.strip.empty? }
|
|
82
|
+
rescue StandardError
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :path
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module TgVizor
|
|
6
|
+
# Thread-safe in-memory event queue with batch draining.
|
|
7
|
+
#
|
|
8
|
+
# Events are pushed one at a time from any thread (handler thread, error
|
|
9
|
+
# callbacks) and drained in batches by the background flush thread. When
|
|
10
|
+
# the queue exceeds +max_size+, the oldest events are dropped to bound
|
|
11
|
+
# memory usage in incident scenarios where ingestion is unreachable.
|
|
12
|
+
class EventQueue
|
|
13
|
+
def initialize(max_size)
|
|
14
|
+
@items = []
|
|
15
|
+
@max_size = max_size
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Push an event to the queue.
|
|
20
|
+
# @return [Boolean] true if the queue overflowed (oldest event dropped).
|
|
21
|
+
def push(event)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@items << event
|
|
24
|
+
if @items.size > @max_size
|
|
25
|
+
@items.shift
|
|
26
|
+
true
|
|
27
|
+
else
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Remove and return up to +batch_size+ events from the front.
|
|
34
|
+
# Caller takes ownership of the returned array.
|
|
35
|
+
def drain(batch_size)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@items.shift(batch_size)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def size
|
|
42
|
+
@mutex.synchronize { @items.size }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def empty?
|
|
46
|
+
size.zero?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def any?
|
|
50
|
+
!empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear
|
|
54
|
+
@mutex.synchronize { @items.clear }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module TgVizor
|
|
6
|
+
module Middleware
|
|
7
|
+
# Middleware for the telegram-bot-ruby gem (https://github.com/atipugin/telegram-bot-ruby).
|
|
8
|
+
#
|
|
9
|
+
# Wraps your update-handling block with auto-tracking of:
|
|
10
|
+
# - the update type (command / message / callback_query / inline_query)
|
|
11
|
+
# - response time (handler duration in ms)
|
|
12
|
+
# - $identify on first-seen-per-process for each user
|
|
13
|
+
# - $error on any StandardError raised by the handler
|
|
14
|
+
# - user_blocked when the handler triggers a 403 from Telegram
|
|
15
|
+
#
|
|
16
|
+
# Re-raises every exception so the host bot's existing error handling stays
|
|
17
|
+
# intact. TgVizor is an observer, not a control-flow modifier.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# require "telegram/bot"
|
|
21
|
+
# require "tgvizor"
|
|
22
|
+
# require "tgvizor/middleware/telegram_bot_ruby"
|
|
23
|
+
#
|
|
24
|
+
# vizor = TgVizor::Client.new(api_key: ENV["TGVIZOR_API_KEY"])
|
|
25
|
+
# tracker = TgVizor::Middleware::TelegramBotRuby.new(vizor)
|
|
26
|
+
#
|
|
27
|
+
# Telegram::Bot::Client.run(TOKEN) do |bot|
|
|
28
|
+
# bot.listen do |update|
|
|
29
|
+
# tracker.track(update) do
|
|
30
|
+
# # your existing handler, unchanged
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
class TelegramBotRuby
|
|
35
|
+
MESSAGE_TYPES = %i[
|
|
36
|
+
text photo voice sticker video document audio animation
|
|
37
|
+
video_note location contact poll dice
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Bound on the in-process "have we identified this user yet?" set.
|
|
41
|
+
# Uncapped, a long-running bot serving millions of users would grow this
|
|
42
|
+
# forever (~80 MB at 1M ids). When the cap is hit we drop a random half —
|
|
43
|
+
# those users will simply re-fire identify on their next interaction,
|
|
44
|
+
# which is harmless.
|
|
45
|
+
SEEN_USERS_CAP = 50_000
|
|
46
|
+
|
|
47
|
+
# Short enough that a spammer can't inflate the events table with a
|
|
48
|
+
# 10k-char wall of text; long enough for most natural-language prompts.
|
|
49
|
+
DEFAULT_MAX_MESSAGE_TEXT_LENGTH = 500
|
|
50
|
+
|
|
51
|
+
# `capture_message_text` is opt-in because plain messages can contain PII
|
|
52
|
+
# (emails, card numbers, personal info). Storing user-generated content
|
|
53
|
+
# carries GDPR obligations most bot owners will want to opt into
|
|
54
|
+
# explicitly. Safe to enable for bots whose plain-text traffic is the
|
|
55
|
+
# signal itself: AI chatbots, translators, URL extractors, search bots.
|
|
56
|
+
# Commands always capture `args` regardless of this setting.
|
|
57
|
+
def initialize(
|
|
58
|
+
client,
|
|
59
|
+
capture_message_text: false,
|
|
60
|
+
max_message_text_length: DEFAULT_MAX_MESSAGE_TEXT_LENGTH
|
|
61
|
+
)
|
|
62
|
+
@client = client
|
|
63
|
+
@seen_users = Set.new
|
|
64
|
+
@seen_mutex = Mutex.new
|
|
65
|
+
@capture_message_text = capture_message_text
|
|
66
|
+
@max_message_text_length = max_message_text_length
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Wrap a single update through the analytics layer.
|
|
70
|
+
#
|
|
71
|
+
# @param update [Telegram::Bot::Types::Base] message, callback query, inline query, ...
|
|
72
|
+
# @yield runs your handler with the update
|
|
73
|
+
# @return [Object] whatever the block returns
|
|
74
|
+
def track(update, &block)
|
|
75
|
+
classification = classify(update)
|
|
76
|
+
user_id = extract_user_id(update)
|
|
77
|
+
started_at = monotonic_now
|
|
78
|
+
|
|
79
|
+
identify_once(update, user_id)
|
|
80
|
+
|
|
81
|
+
block.call
|
|
82
|
+
rescue Telegram::Bot::Exceptions::ResponseError => e
|
|
83
|
+
if e.error_code.to_i == 403
|
|
84
|
+
# Prefer the specific command text (e.g. "/promo_weekly") over the generic
|
|
85
|
+
# event class — bot owners want to know which action drove the block.
|
|
86
|
+
last_action = classification[:command] || classification[:event] || "unknown"
|
|
87
|
+
@client.track(
|
|
88
|
+
"user_blocked",
|
|
89
|
+
user_id: user_id,
|
|
90
|
+
properties: { last_action: last_action },
|
|
91
|
+
)
|
|
92
|
+
else
|
|
93
|
+
@client.capture_error(e, user_id: user_id, command: classification[:command])
|
|
94
|
+
end
|
|
95
|
+
raise
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
@client.capture_error(e, user_id: user_id, command: classification[:command])
|
|
98
|
+
raise
|
|
99
|
+
ensure
|
|
100
|
+
if classification && classification[:event]
|
|
101
|
+
elapsed_ms = started_at ? ((monotonic_now - started_at) * 1000).round : nil
|
|
102
|
+
emit_classified_event(classification, user_id, elapsed_ms)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Classify the update into an event-name + properties payload.
|
|
109
|
+
# Returns nil for update kinds we don't track.
|
|
110
|
+
def classify(update)
|
|
111
|
+
return classify_message(update) if message?(update)
|
|
112
|
+
return classify_callback_query(update) if callback_query?(update)
|
|
113
|
+
return classify_inline_query(update) if inline_query?(update)
|
|
114
|
+
|
|
115
|
+
{ event: nil }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def classify_message(message)
|
|
119
|
+
text = message.respond_to?(:text) ? message.text : nil
|
|
120
|
+
|
|
121
|
+
if text && text.start_with?("/")
|
|
122
|
+
command, args = text.split(" ", 2)
|
|
123
|
+
# Strip @BotName suffix from /start@MyBot
|
|
124
|
+
command = command.split("@", 2).first
|
|
125
|
+
return {
|
|
126
|
+
event: "command",
|
|
127
|
+
command: command,
|
|
128
|
+
properties: { command: command, args: args },
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
type = MESSAGE_TYPES.find { |t| message.respond_to?(t) && !message.public_send(t).nil? }
|
|
133
|
+
properties = { type: (type || :text).to_s }
|
|
134
|
+
|
|
135
|
+
if @capture_message_text && text && !text.empty?
|
|
136
|
+
properties[:text] = truncate_text(text)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
event: "message",
|
|
141
|
+
properties: properties,
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def truncate_text(text)
|
|
146
|
+
return text if text.length <= @max_message_text_length
|
|
147
|
+
|
|
148
|
+
text[0, @max_message_text_length] + "…"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def classify_callback_query(query)
|
|
152
|
+
data = query.respond_to?(:data) ? query.data : nil
|
|
153
|
+
{
|
|
154
|
+
event: "callback_query",
|
|
155
|
+
properties: { data: data },
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def classify_inline_query(query)
|
|
160
|
+
text = query.respond_to?(:query) ? query.query : nil
|
|
161
|
+
{
|
|
162
|
+
event: "inline_query",
|
|
163
|
+
properties: { query: text },
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def message?(update)
|
|
168
|
+
defined?(Telegram::Bot::Types::Message) && update.is_a?(Telegram::Bot::Types::Message)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def callback_query?(update)
|
|
172
|
+
defined?(Telegram::Bot::Types::CallbackQuery) && update.is_a?(Telegram::Bot::Types::CallbackQuery)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def inline_query?(update)
|
|
176
|
+
defined?(Telegram::Bot::Types::InlineQuery) && update.is_a?(Telegram::Bot::Types::InlineQuery)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def extract_user_id(update)
|
|
180
|
+
return nil unless update.respond_to?(:from) && update.from
|
|
181
|
+
|
|
182
|
+
update.from.respond_to?(:id) ? update.from.id : nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def identify_once(update, user_id)
|
|
186
|
+
return unless user_id
|
|
187
|
+
|
|
188
|
+
already_seen = @seen_mutex.synchronize do
|
|
189
|
+
if @seen_users.size >= SEEN_USERS_CAP
|
|
190
|
+
# Drop the oldest half by walking the underlying hash. Set is
|
|
191
|
+
# insertion-ordered in Ruby 3+, so this evicts the longest-standing
|
|
192
|
+
# entries — a poor-man's LRU without an extra dependency.
|
|
193
|
+
half = SEEN_USERS_CAP / 2
|
|
194
|
+
@seen_users = Set.new(@seen_users.to_a.last(half))
|
|
195
|
+
end
|
|
196
|
+
!@seen_users.add?(user_id)
|
|
197
|
+
end
|
|
198
|
+
return if already_seen
|
|
199
|
+
return unless update.respond_to?(:from) && update.from
|
|
200
|
+
|
|
201
|
+
from = update.from
|
|
202
|
+
traits = {}
|
|
203
|
+
traits[:username] = from.username if from.respond_to?(:username) && from.username
|
|
204
|
+
traits[:first_name] = from.first_name if from.respond_to?(:first_name) && from.first_name
|
|
205
|
+
traits[:last_name] = from.last_name if from.respond_to?(:last_name) && from.last_name
|
|
206
|
+
traits[:language_code] = from.language_code if from.respond_to?(:language_code) && from.language_code
|
|
207
|
+
traits[:is_premium] = from.is_premium if from.respond_to?(:is_premium) && !from.is_premium.nil?
|
|
208
|
+
|
|
209
|
+
@client.identify(user_id, traits)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Emit a single event row with the perf data attached. The Performance
|
|
213
|
+
# dashboard reads `properties->>'_response_time_ms'` so this row both
|
|
214
|
+
# records the action AND fuels the performance page — half the events
|
|
215
|
+
# of the original "two-event-per-handler" approach.
|
|
216
|
+
def emit_classified_event(classification, user_id, elapsed_ms)
|
|
217
|
+
properties = (classification[:properties] || {}).dup
|
|
218
|
+
if elapsed_ms
|
|
219
|
+
properties[:_response_time_ms] = elapsed_ms
|
|
220
|
+
properties[:_response_time_handler] = classification[:command] || classification[:event]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
@client.track(
|
|
224
|
+
classification[:event],
|
|
225
|
+
user_id: user_id,
|
|
226
|
+
properties: properties,
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def monotonic_now
|
|
231
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module TgVizor
|
|
8
|
+
# HTTP transport for sending event batches to the ingestion API.
|
|
9
|
+
#
|
|
10
|
+
# Uses Net::HTTP from stdlib (zero runtime deps). Retries with exponential
|
|
11
|
+
# backoff + jitter on 5xx, 429, and network failures. Treats 4xx other than
|
|
12
|
+
# 429 as non-retryable (bad request, unauthorized — caller's fault).
|
|
13
|
+
class Transport
|
|
14
|
+
# Outcome of a single send attempt.
|
|
15
|
+
#
|
|
16
|
+
# @!attribute ok
|
|
17
|
+
# @return [Boolean] true on 202 Accepted
|
|
18
|
+
# @!attribute retryable
|
|
19
|
+
# @return [Boolean] true if the caller should persist and retry later
|
|
20
|
+
# @!attribute status
|
|
21
|
+
# @return [Integer, nil] HTTP status code, if any
|
|
22
|
+
# @!attribute message
|
|
23
|
+
# @return [String, nil] human-readable error message
|
|
24
|
+
Result = Struct.new(:ok, :retryable, :status, :message, keyword_init: true) do
|
|
25
|
+
def ok? = ok
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
OPEN_TIMEOUT = 5 # seconds
|
|
29
|
+
READ_TIMEOUT = 10 # seconds
|
|
30
|
+
|
|
31
|
+
def initialize(endpoint:, api_key:, max_retries: 5, debug: false, logger: nil)
|
|
32
|
+
@uri = URI.join(endpoint.chomp("/") + "/", "v1/events")
|
|
33
|
+
@api_key = api_key
|
|
34
|
+
@max_retries = max_retries
|
|
35
|
+
@debug = debug
|
|
36
|
+
@logger = logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Send a batch of events.
|
|
40
|
+
# @param events [Array<Hash>] queued events
|
|
41
|
+
# @return [Result]
|
|
42
|
+
def send_batch(events)
|
|
43
|
+
body = JSON.generate(events: events)
|
|
44
|
+
|
|
45
|
+
# One HTTP connection across all retries — saves a TCP+TLS handshake
|
|
46
|
+
# (~150-300ms) on every retry attempt and lets keep-alive coalesce
|
|
47
|
+
# follow-up batches in the same flush.
|
|
48
|
+
http = build_http
|
|
49
|
+
http.start
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
(0..@max_retries).each do |attempt|
|
|
53
|
+
sleep(backoff_seconds(attempt)) if attempt.positive?
|
|
54
|
+
|
|
55
|
+
result = attempt_send(http, body, attempt)
|
|
56
|
+
return result if result
|
|
57
|
+
|
|
58
|
+
# nil result means retryable — loop continues
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Result.new(ok: false, retryable: true, message: "max retries exceeded")
|
|
62
|
+
ensure
|
|
63
|
+
http.finish if http.started?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_http
|
|
70
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
71
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
72
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
73
|
+
http.read_timeout = READ_TIMEOUT
|
|
74
|
+
http.keep_alive_timeout = 30
|
|
75
|
+
http
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns a Result on terminal outcome (success or non-retryable failure),
|
|
79
|
+
# or nil to signal "retry the loop".
|
|
80
|
+
def attempt_send(http, body, attempt)
|
|
81
|
+
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
82
|
+
request["Content-Type"] = "application/json"
|
|
83
|
+
request["X-API-Key"] = @api_key
|
|
84
|
+
request.body = body
|
|
85
|
+
|
|
86
|
+
response = http.request(request)
|
|
87
|
+
handle_response(response, attempt)
|
|
88
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
|
|
89
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError, EOFError, IOError => e
|
|
90
|
+
debug_log("network error on attempt #{attempt + 1}: #{e.class} #{e.message}")
|
|
91
|
+
return Result.new(ok: false, retryable: true, message: e.message) if attempt == @max_retries
|
|
92
|
+
|
|
93
|
+
nil # retry
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_response(response, attempt)
|
|
97
|
+
code = response.code.to_i
|
|
98
|
+
|
|
99
|
+
return Result.new(ok: true, retryable: false, status: 202) if code == 202
|
|
100
|
+
|
|
101
|
+
if code == 401
|
|
102
|
+
return Result.new(ok: false, retryable: false, status: 401, message: "unauthorized")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if code == 429
|
|
106
|
+
debug_log("rate limited (429) on attempt #{attempt + 1}")
|
|
107
|
+
return nil # retry
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if code >= 500
|
|
111
|
+
debug_log("server error #{code} on attempt #{attempt + 1}")
|
|
112
|
+
return nil # retry
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Other 4xx — caller's fault, don't retry.
|
|
116
|
+
message = parse_message(response.body) || "unexpected status #{code}"
|
|
117
|
+
Result.new(ok: false, retryable: false, status: code, message: message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_message(body)
|
|
121
|
+
return nil if body.nil? || body.empty?
|
|
122
|
+
|
|
123
|
+
JSON.parse(body)["message"]
|
|
124
|
+
rescue JSON::ParserError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Exponential backoff with full jitter: ~1s, ~2s, ~4s, ~8s, ~16s, capped at 30s.
|
|
129
|
+
def backoff_seconds(attempt)
|
|
130
|
+
base = [1.0 * (2**(attempt - 1)), 30.0].min
|
|
131
|
+
base + (rand * base * 0.5)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def debug_log(msg)
|
|
135
|
+
return unless @debug
|
|
136
|
+
|
|
137
|
+
if @logger
|
|
138
|
+
@logger.debug("[tgvizor] #{msg}")
|
|
139
|
+
else
|
|
140
|
+
warn("[tgvizor] #{msg}")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/tgvizor.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tgvizor/version"
|
|
4
|
+
require_relative "tgvizor/event_queue"
|
|
5
|
+
require_relative "tgvizor/transport"
|
|
6
|
+
require_relative "tgvizor/disk_queue"
|
|
7
|
+
require_relative "tgvizor/client"
|
|
8
|
+
|
|
9
|
+
# TgVizor — analytics SDK for Telegram bots.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# vizor = TgVizor::Client.new(api_key: ENV["TGVIZOR_API_KEY"])
|
|
13
|
+
# vizor.track("purchase", user_id: 123, properties: { amount: 9.99 })
|
|
14
|
+
# vizor.identify(123, username: "john", first_name: "John")
|
|
15
|
+
# vizor.shutdown!
|
|
16
|
+
module TgVizor
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tgvizor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- TGVizor
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-04-25 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: webmock
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.23'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.23'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
description: Track events, errors, performance, and user behavior in your Telegram
|
|
55
|
+
bot with one line of code. Pairs with telegram-bot-ruby.
|
|
56
|
+
email:
|
|
57
|
+
- hello@tgvizor.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- LICENSE
|
|
63
|
+
- README.md
|
|
64
|
+
- lib/tgvizor.rb
|
|
65
|
+
- lib/tgvizor/client.rb
|
|
66
|
+
- lib/tgvizor/disk_queue.rb
|
|
67
|
+
- lib/tgvizor/event_queue.rb
|
|
68
|
+
- lib/tgvizor/middleware/telegram_bot_ruby.rb
|
|
69
|
+
- lib/tgvizor/transport.rb
|
|
70
|
+
- lib/tgvizor/version.rb
|
|
71
|
+
homepage: https://tgvizor.com
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://tgvizor.com
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '3.1'
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.6.2
|
|
92
|
+
specification_version: 4
|
|
93
|
+
summary: Analytics SDK for Telegram bots
|
|
94
|
+
test_files: []
|