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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TgVizor
4
+ VERSION = "0.1.0"
5
+ 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: []