peekapi 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: 7d0778bfe11ccab833051a6848f020268b249a1d3fc039c4c832d07063691b19
4
+ data.tar.gz: aa6959fb0c64f8e29e8f63651eefbacac4c402523f70a46aab32d44e1ed84d58
5
+ SHA512:
6
+ metadata.gz: ed73170da82b86dbd618c365b27d38a95ec2f496b9afc1c21bdf3d1ee3bc3cd2a972df0647f8564895eb927746f85c0d46fabcc363e5299b4414370bfd42be2e
7
+ data.tar.gz: 0d67f21724318299c9441f6400bdaca42fa093352fda737eeab1f57147d155e3ef8439cdc097a320dd3d4f197aa3b91e270b231e032125ec20874ac794426a8c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PeekAPI
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,152 @@
1
+ # PeekAPI — Ruby SDK
2
+
3
+ [![Gem](https://img.shields.io/gem/v/peekapi)](https://rubygems.org/gems/peekapi)
4
+ [![license](https://img.shields.io/gem/l/peekapi)](./LICENSE)
5
+ [![CI](https://github.com/peekapi-dev/sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/peekapi-dev/sdk-ruby/actions/workflows/ci.yml)
6
+
7
+ Zero-dependency Ruby SDK for [PeekAPI](https://peekapi.dev). Rack middleware that works with Rails, Sinatra, Hanami, and any Rack-compatible framework. Rails auto-integrates via Railtie.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ gem install peekapi
13
+ ```
14
+
15
+ Or add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "peekapi"
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ### Rails (auto-integration)
24
+
25
+ Set environment variables and the Railtie handles everything:
26
+
27
+ ```bash
28
+ export PEEKAPI_API_KEY=ak_live_xxx
29
+ export PEEKAPI_ENDPOINT=https://...
30
+ ```
31
+
32
+ The SDK auto-inserts Rack middleware and registers a shutdown hook. No code changes needed.
33
+
34
+ ### Rails (manual)
35
+
36
+ ```ruby
37
+ # config/application.rb
38
+ client = PeekApi::Client.new(api_key: "ak_live_xxx")
39
+ config.middleware.use PeekApi::Middleware::Rack, client: client
40
+ ```
41
+
42
+ ### Sinatra
43
+
44
+ ```ruby
45
+ require "sinatra"
46
+ require "peekapi"
47
+
48
+ client = PeekApi::Client.new(api_key: "ak_live_xxx")
49
+ use PeekApi::Middleware::Rack, client: client
50
+
51
+ get "/api/hello" do
52
+ json message: "hello"
53
+ end
54
+ ```
55
+
56
+ ### Hanami
57
+
58
+ ```ruby
59
+ # config/app.rb
60
+ require "peekapi"
61
+
62
+ client = PeekApi::Client.new(api_key: "ak_live_xxx")
63
+ middleware.use PeekApi::Middleware::Rack, client: client
64
+ ```
65
+
66
+ ### Standalone Client
67
+
68
+ ```ruby
69
+ require "peekapi"
70
+
71
+ client = PeekApi::Client.new(api_key: "ak_live_xxx")
72
+
73
+ client.track(
74
+ method: "GET",
75
+ path: "/api/users",
76
+ status_code: 200,
77
+ response_time_ms: 42,
78
+ )
79
+
80
+ # Graceful shutdown (flushes remaining events)
81
+ client.shutdown
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ | Option | Default | Description |
87
+ |---|---|---|
88
+ | `api_key` | required | Your PeekAPI key |
89
+ | `endpoint` | PeekAPI cloud | Ingestion endpoint URL |
90
+ | `flush_interval` | `10` | Seconds between automatic flushes |
91
+ | `batch_size` | `100` | Events per HTTP POST (triggers flush) |
92
+ | `max_buffer_size` | `10_000` | Max events held in memory |
93
+ | `max_storage_bytes` | `5_242_880` | Max disk fallback file size (5MB) |
94
+ | `max_event_bytes` | `65_536` | Per-event size limit (64KB) |
95
+ | `storage_path` | auto | Custom path for JSONL persistence file |
96
+ | `debug` | `false` | Enable debug logging to stderr |
97
+ | `on_error` | `nil` | Callback `Proc` invoked with `Exception` on flush errors |
98
+
99
+ ## How It Works
100
+
101
+ 1. Rack middleware intercepts every request/response
102
+ 2. Captures method, path, status code, response time, request/response sizes, consumer ID
103
+ 3. Events are buffered in memory and flushed in batches on a background thread
104
+ 4. On network failure: exponential backoff with jitter, up to 5 retries
105
+ 5. After max retries: events are persisted to a JSONL file on disk
106
+ 6. On next startup: persisted events are recovered and re-sent
107
+ 7. On SIGTERM/SIGINT: remaining buffer is flushed or persisted to disk
108
+
109
+ ## Consumer Identification
110
+
111
+ By default, consumers are identified by:
112
+
113
+ 1. `X-API-Key` header — stored as-is
114
+ 2. `Authorization` header — hashed with SHA-256 (stored as `hash_<hex>`)
115
+
116
+ Override with the `identify_consumer` option to use any header:
117
+
118
+ ```ruby
119
+ client = PeekApi::Client.new(
120
+ api_key: "...",
121
+ identify_consumer: ->(headers) { headers["x-tenant-id"] }
122
+ )
123
+ ```
124
+
125
+ The callback receives a `Hash` of lowercase header names and should return a consumer ID string or `nil`.
126
+
127
+ ## Features
128
+
129
+ - **Zero runtime dependencies** — uses only Ruby stdlib (`net/http`, `json`, `digest`)
130
+ - **Background flush** — dedicated thread with configurable interval and batch size
131
+ - **Disk persistence** — undelivered events saved to JSONL, recovered on restart
132
+ - **Exponential backoff** — with jitter, max 5 consecutive failures before disk fallback
133
+ - **SSRF protection** — private IP blocking, HTTPS enforcement (HTTP only for localhost)
134
+ - **Input sanitization** — path (2048), method (16), consumer_id (256) truncation
135
+ - **Per-event size limit** — strips metadata first, drops if still too large (default 64KB)
136
+ - **Graceful shutdown** — SIGTERM/SIGINT handlers + `at_exit` fallback
137
+ - **Rails Railtie** — auto-configures from env vars when Rails is detected
138
+
139
+ ## Requirements
140
+
141
+ - Ruby >= 3.1
142
+
143
+ ## Contributing
144
+
145
+ 1. Fork & clone the repo
146
+ 2. Install dependencies — `bundle install`
147
+ 3. Run tests — `bundle exec rake test`
148
+ 4. Submit a PR
149
+
150
+ ## License
151
+
152
+ MIT
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'digest/sha2'
7
+ require 'tempfile'
8
+ require 'fileutils'
9
+
10
+ module PeekApi
11
+ class Client
12
+ # --- Constants ---
13
+ DEFAULT_ENDPOINT = 'https://ingest.peekapi.dev/v1/events'
14
+ DEFAULT_FLUSH_INTERVAL = 15 # seconds
15
+ DEFAULT_BATCH_SIZE = 250
16
+ DEFAULT_MAX_BUFFER_SIZE = 10_000
17
+ DEFAULT_MAX_STORAGE_BYTES = 5_242_880 # 5 MB
18
+ DEFAULT_MAX_EVENT_BYTES = 65_536 # 64 KB
19
+ MAX_PATH_LENGTH = 2_048
20
+ MAX_METHOD_LENGTH = 16
21
+ MAX_CONSUMER_ID_LENGTH = 256
22
+ MAX_CONSECUTIVE_FAILURES = 5
23
+ BASE_BACKOFF_S = 1.0
24
+ SEND_TIMEOUT_S = 5
25
+ DISK_RECOVERY_INTERVAL_S = 60
26
+ RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504].freeze
27
+
28
+ attr_reader :api_key, :endpoint, :identify_consumer, :collect_query_string
29
+
30
+ # @param options [Hash]
31
+ # @option options [String] :api_key Required. Your API key.
32
+ # @option options [String] :endpoint Ingestion endpoint URL (default: PeekAPI cloud).
33
+ # @option options [Numeric] :flush_interval Seconds between background flushes (default 15).
34
+ # @option options [Integer] :batch_size Max events per HTTP POST (default 250).
35
+ # @option options [Integer] :max_buffer_size Max buffered events (default 10_000).
36
+ # @option options [Integer] :max_storage_bytes Max bytes for disk persistence (default 5 MB).
37
+ # @option options [Integer] :max_event_bytes Max bytes per single event (default 64 KB).
38
+ # @option options [String] :storage_path Custom path for JSONL persistence file.
39
+ # @option options [Boolean] :debug Enable debug logging to $stderr.
40
+ # @option options [Proc] :on_error Callback invoked with Exception on flush failure.
41
+ def initialize(options = {})
42
+ @api_key = options[:api_key] || options['api_key']
43
+ raise ArgumentError, 'api_key is required' if @api_key.nil? || @api_key.empty?
44
+ raise ArgumentError, 'api_key contains invalid control characters' if @api_key.match?(/[\x00-\x1f\x7f]/)
45
+
46
+ raw_endpoint = options[:endpoint] || options['endpoint'] || DEFAULT_ENDPOINT
47
+ @endpoint = SSRF.validate_endpoint!(raw_endpoint)
48
+
49
+ @flush_interval = (options[:flush_interval] || options['flush_interval'] || DEFAULT_FLUSH_INTERVAL).to_f
50
+ @batch_size = (options[:batch_size] || options['batch_size'] || DEFAULT_BATCH_SIZE).to_i
51
+ @max_buffer_size = (options[:max_buffer_size] || options['max_buffer_size'] || DEFAULT_MAX_BUFFER_SIZE).to_i
52
+ @max_storage_bytes =
53
+ (options[:max_storage_bytes] || options['max_storage_bytes'] || DEFAULT_MAX_STORAGE_BYTES).to_i
54
+ @max_event_bytes = (options[:max_event_bytes] || options['max_event_bytes'] || DEFAULT_MAX_EVENT_BYTES).to_i
55
+ @debug = options[:debug] || options['debug'] || false
56
+ @identify_consumer = options[:identify_consumer] || options['identify_consumer']
57
+ @on_error = options[:on_error] || options['on_error']
58
+ # NOTE: increases DB usage — each unique path+query creates a separate endpoint row.
59
+ @collect_query_string = options[:collect_query_string] || options['collect_query_string'] || false
60
+
61
+ # Storage path
62
+ storage = options[:storage_path] || options['storage_path']
63
+ if storage
64
+ @storage_path = storage
65
+ else
66
+ h = Digest::SHA256.hexdigest(@endpoint)[0, 12]
67
+ @storage_path = File.join(Dir.tmpdir, "peekapi-events-#{h}.jsonl")
68
+ end
69
+
70
+ @recovery_path = nil
71
+
72
+ # Internal state
73
+ @buffer = []
74
+ @mutex = Mutex.new
75
+ @in_flight = false
76
+ @consecutive_failures = 0
77
+ @backoff_until = 0.0
78
+ @shutdown = false
79
+ @last_disk_recovery = Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+
81
+ # Load persisted events from disk
82
+ load_from_disk
83
+
84
+ # Background flush thread
85
+ @done = false
86
+ @wake = Queue.new
87
+ @thread = Thread.new { run_loop }
88
+
89
+ # Signal handlers (only from main thread)
90
+ @original_handlers = {}
91
+ if Thread.current == Thread.main
92
+ %i[TERM INT].each do |sig|
93
+ prev = Signal.trap(sig) { signal_handler(sig) }
94
+ @original_handlers[sig] = prev
95
+ rescue ArgumentError
96
+ # Signal not supported on this platform
97
+ end
98
+ end
99
+
100
+ # at_exit fallback
101
+ at_exit { shutdown_sync }
102
+ end
103
+
104
+ # Buffer an analytics event. Never raises.
105
+ def track(event)
106
+ track_inner(event)
107
+ rescue StandardError => e
108
+ warn "peekapi: track() error: #{e.message}" if @debug
109
+ end
110
+
111
+ # Flush buffered events synchronously (blocks until complete).
112
+ def flush
113
+ batch = drain_batch
114
+ return if batch.empty?
115
+
116
+ do_flush(batch)
117
+ end
118
+
119
+ # Graceful shutdown: stop thread, final flush, persist remainder.
120
+ def shutdown
121
+ return if @shutdown
122
+
123
+ @shutdown = true
124
+
125
+ # Remove signal handlers
126
+ @original_handlers.each do |sig, handler|
127
+ Signal.trap(sig, handler)
128
+ rescue ArgumentError
129
+ # ignore
130
+ end
131
+ @original_handlers.clear
132
+
133
+ # Stop background thread
134
+ @done = true
135
+ @wake << :stop
136
+ @thread.join(5)
137
+
138
+ # Final flush
139
+ flush
140
+
141
+ # Persist remainder
142
+ remaining = @mutex.synchronize do
143
+ buf = @buffer.dup
144
+ @buffer.clear
145
+ buf
146
+ end
147
+ persist_to_disk(remaining) unless remaining.empty?
148
+ end
149
+
150
+ private
151
+
152
+ # ----------------------------------------------------------------
153
+ # Track internals
154
+ # ----------------------------------------------------------------
155
+
156
+ def track_inner(event)
157
+ return if @shutdown
158
+
159
+ d = event.is_a?(Hash) ? event.transform_keys(&:to_s) : event.to_h.transform_keys(&:to_s)
160
+
161
+ # Sanitize
162
+ d['method'] = d.fetch('method', '').to_s[0, MAX_METHOD_LENGTH].upcase
163
+ d['path'] = d.fetch('path', '').to_s[0, MAX_PATH_LENGTH]
164
+ d['consumer_id'] = d['consumer_id'].to_s[0, MAX_CONSUMER_ID_LENGTH] if d['consumer_id']
165
+
166
+ # Timestamp
167
+ d['timestamp'] ||= Time.now.utc.iso8601(3)
168
+
169
+ # Per-event size limit
170
+ raw = JSON.generate(d)
171
+ if raw.bytesize > @max_event_bytes
172
+ d.delete('metadata')
173
+ raw = JSON.generate(d)
174
+ if raw.bytesize > @max_event_bytes
175
+ warn "peekapi: event too large, dropping (#{raw.bytesize} bytes)" if @debug
176
+ return
177
+ end
178
+ end
179
+
180
+ @mutex.synchronize do
181
+ if @buffer.size >= @max_buffer_size
182
+ # Buffer full — trigger flush instead of dropping
183
+ @wake << :flush
184
+ return
185
+ end
186
+ @buffer << d
187
+ size = @buffer.size
188
+ @wake << :flush if size >= @batch_size
189
+ end
190
+ end
191
+
192
+ # ----------------------------------------------------------------
193
+ # Flush internals
194
+ # ----------------------------------------------------------------
195
+
196
+ def drain_batch
197
+ @mutex.synchronize do
198
+ return [] if @buffer.empty? || @in_flight
199
+
200
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
201
+ return [] if now < @backoff_until
202
+
203
+ batch = @buffer.shift(@batch_size)
204
+ @in_flight = true
205
+ batch
206
+ end
207
+ end
208
+
209
+ def do_flush(batch)
210
+ send_events(batch)
211
+
212
+ # Success
213
+ @mutex.synchronize do
214
+ @consecutive_failures = 0
215
+ @backoff_until = 0.0
216
+ @in_flight = false
217
+ end
218
+ cleanup_recovery_file
219
+ warn "peekapi: flushed #{batch.size} events" if @debug
220
+ rescue NonRetryableError => e
221
+ @mutex.synchronize { @in_flight = false }
222
+ persist_to_disk(batch)
223
+ call_on_error(e)
224
+ warn "peekapi: non-retryable error, persisted to disk: #{e.message}" if @debug
225
+ rescue StandardError => e
226
+ failures = @mutex.synchronize do
227
+ @consecutive_failures += 1
228
+ f = @consecutive_failures
229
+
230
+ if f >= MAX_CONSECUTIVE_FAILURES
231
+ @consecutive_failures = 0
232
+ @in_flight = false
233
+ persist_to_disk(batch)
234
+ else
235
+ # Re-insert at front
236
+ space = @max_buffer_size - @buffer.size
237
+ reinsert = batch[0, space]
238
+ @buffer.unshift(*reinsert) unless reinsert.empty?
239
+ # Exponential backoff with jitter
240
+ delay = BASE_BACKOFF_S * (2**(f - 1)) * rand(0.5..1.0)
241
+ @backoff_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
242
+ @in_flight = false
243
+ end
244
+
245
+ f
246
+ end
247
+
248
+ call_on_error(e)
249
+ warn "peekapi: flush failed (attempt #{failures}): #{e.message}" if @debug
250
+ end
251
+
252
+ def send_events(events)
253
+ uri = URI.parse(@endpoint)
254
+ body = JSON.generate(events)
255
+
256
+ http = Net::HTTP.new(uri.host, uri.port)
257
+ http.use_ssl = (uri.scheme == 'https')
258
+ http.open_timeout = SEND_TIMEOUT_S
259
+ http.read_timeout = SEND_TIMEOUT_S
260
+
261
+ request = Net::HTTP::Post.new(uri.request_uri)
262
+ request['Content-Type'] = 'application/json'
263
+ request['x-api-key'] = @api_key
264
+ request['x-peekapi-sdk'] = "ruby/#{VERSION}"
265
+ request.body = body
266
+
267
+ response = http.request(request)
268
+ status = response.code.to_i
269
+
270
+ if status >= 200 && status < 300
271
+ nil
272
+ elsif RETRYABLE_STATUS_CODES.include?(status)
273
+ raise RetryableError, "HTTP #{status}: #{response.body&.[](0, 1024)}"
274
+ else
275
+ raise NonRetryableError, "HTTP #{status}: #{response.body&.[](0, 1024)}"
276
+ end
277
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
278
+ Net::OpenTimeout, Net::ReadTimeout, SocketError => e
279
+ raise RetryableError, "Network error: #{e.message}"
280
+ end
281
+
282
+ # ----------------------------------------------------------------
283
+ # Background thread
284
+ # ----------------------------------------------------------------
285
+
286
+ def run_loop
287
+ until @done
288
+ # Wait for wake signal or timeout
289
+ begin
290
+ @wake.pop(timeout: @flush_interval)
291
+ rescue ThreadError
292
+ # Timeout — proceed to flush
293
+ end
294
+ break if @done
295
+
296
+ batch = drain_batch
297
+ do_flush(batch) unless batch.empty?
298
+
299
+ # Periodically recover persisted events from disk
300
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
301
+ if now - @last_disk_recovery >= DISK_RECOVERY_INTERVAL_S
302
+ @last_disk_recovery = now
303
+ load_from_disk
304
+ end
305
+ end
306
+ end
307
+
308
+ # ----------------------------------------------------------------
309
+ # Disk persistence
310
+ # ----------------------------------------------------------------
311
+
312
+ def persist_to_disk(events)
313
+ return if events.empty?
314
+
315
+ path = @storage_path
316
+ size = begin
317
+ File.size(path)
318
+ rescue StandardError
319
+ 0
320
+ end
321
+
322
+ if size >= @max_storage_bytes
323
+ warn "peekapi: storage file full, dropping #{events.size} events" if @debug
324
+ return
325
+ end
326
+
327
+ line = "#{JSON.generate(events)}\n"
328
+ File.open(path, 'a', 0o600) { |f| f.write(line) }
329
+ rescue StandardError => e
330
+ warn "peekapi: disk persist failed: #{e.message}" if @debug
331
+ end
332
+
333
+ def load_from_disk
334
+ recovery = "#{@storage_path}.recovering"
335
+
336
+ [recovery, @storage_path].each do |path|
337
+ next unless File.file?(path)
338
+
339
+ begin
340
+ content = File.read(path, encoding: 'utf-8')
341
+ events = []
342
+ content.each_line do |line|
343
+ line = line.strip
344
+ next if line.empty?
345
+
346
+ begin
347
+ parsed = JSON.parse(line)
348
+ if parsed.is_a?(Array)
349
+ events.concat(parsed)
350
+ elsif parsed.is_a?(Hash)
351
+ events << parsed
352
+ end
353
+ rescue JSON::ParserError
354
+ next
355
+ end
356
+ break if events.size >= @max_buffer_size
357
+ end
358
+
359
+ unless events.empty?
360
+ @mutex.synchronize do
361
+ space = @max_buffer_size - @buffer.size
362
+ @buffer.concat(events[0, space]) if space.positive?
363
+ end
364
+ warn "peekapi: loaded #{events.size} events from disk" if @debug
365
+ end
366
+
367
+ # Rename to .recovering so we don't double-load
368
+ if path == @storage_path
369
+ rpath = "#{@storage_path}.recovering"
370
+ begin
371
+ File.rename(path, rpath)
372
+ rescue SystemCallError
373
+ begin
374
+ File.delete(path)
375
+ rescue StandardError
376
+ nil
377
+ end
378
+ end
379
+ @recovery_path = rpath
380
+ else
381
+ @recovery_path = path
382
+ end
383
+
384
+ break # loaded from one file, done
385
+ rescue StandardError => e
386
+ warn "peekapi: disk load failed from #{path}: #{e.message}" if @debug
387
+ end
388
+ end
389
+ end
390
+
391
+ def cleanup_recovery_file
392
+ return unless @recovery_path
393
+
394
+ begin
395
+ File.delete(@recovery_path)
396
+ rescue StandardError
397
+ nil
398
+ end
399
+ @recovery_path = nil
400
+ end
401
+
402
+ # ----------------------------------------------------------------
403
+ # Signal / at_exit handlers
404
+ # ----------------------------------------------------------------
405
+
406
+ def signal_handler(sig)
407
+ shutdown_sync
408
+ # Re-raise with original handler
409
+ handler = @original_handlers[sig]
410
+ if handler.is_a?(Proc)
411
+ handler.call
412
+ elsif handler == 'DEFAULT'
413
+ Signal.trap(sig, 'DEFAULT')
414
+ Process.kill(sig, Process.pid)
415
+ end
416
+ end
417
+
418
+ def shutdown_sync
419
+ return if @shutdown
420
+
421
+ @shutdown = true
422
+ @done = true
423
+ begin
424
+ @wake << :stop
425
+ rescue StandardError
426
+ nil
427
+ end
428
+
429
+ remaining = @mutex.synchronize do
430
+ buf = @buffer.dup
431
+ @buffer.clear
432
+ buf
433
+ end
434
+ persist_to_disk(remaining) unless remaining.empty?
435
+ end
436
+
437
+ # ----------------------------------------------------------------
438
+ # Helpers
439
+ # ----------------------------------------------------------------
440
+
441
+ def call_on_error(exc)
442
+ return unless @on_error
443
+
444
+ begin
445
+ @on_error.call(exc)
446
+ rescue StandardError
447
+ nil
448
+ end
449
+ end
450
+
451
+ # Error classes
452
+ class RetryableError < StandardError; end
453
+ class NonRetryableError < StandardError; end
454
+ end
455
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha2'
4
+
5
+ module PeekApi
6
+ module Consumer
7
+ module_function
8
+
9
+ # SHA-256 hash truncated to 12 hex chars, prefixed with "hash_".
10
+ def hash_consumer_id(raw)
11
+ digest = Digest::SHA256.hexdigest(raw)[0, 12]
12
+ "hash_#{digest}"
13
+ end
14
+
15
+ # Identify consumer from request headers.
16
+ #
17
+ # Priority:
18
+ # 1. x-api-key (stored as-is)
19
+ # 2. Authorization (hashed — contains credentials)
20
+ #
21
+ # Headers keys are expected to be lowercase (Rack convention).
22
+ def default_identify_consumer(headers)
23
+ api_key = headers['x-api-key']
24
+ return api_key if api_key && !api_key.empty?
25
+
26
+ auth = headers['authorization']
27
+ return hash_consumer_id(auth) if auth && !auth.empty?
28
+
29
+ nil
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PeekApi
4
+ module Middleware
5
+ # Rack middleware that tracks HTTP request analytics.
6
+ #
7
+ # Usage (Sinatra):
8
+ #
9
+ # client = PeekApi::Client.new(api_key: "...", endpoint: "...")
10
+ # use PeekApi::Middleware::Rack, client: client
11
+ #
12
+ # Usage (Rails):
13
+ #
14
+ # config.middleware.use PeekApi::Middleware::Rack, client: client
15
+ #
16
+ class Rack
17
+ def initialize(app, client: nil)
18
+ @app = app
19
+ @client = client
20
+ end
21
+
22
+ def call(env)
23
+ return @app.call(env) if @client.nil?
24
+
25
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+ status, headers, body = @app.call(env)
27
+
28
+ begin
29
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
30
+
31
+ # Measure response size
32
+ response_size = 0
33
+ if headers['content-length']
34
+ response_size = headers['content-length'].to_i
35
+ else
36
+ begin
37
+ body.each { |chunk| response_size += chunk.bytesize }
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
42
+
43
+ consumer_id = identify_consumer(env)
44
+ path = env['PATH_INFO'] || '/'
45
+ if @client.collect_query_string
46
+ qs = env['QUERY_STRING'].to_s
47
+ unless qs.empty?
48
+ sorted = qs.split('&').sort.join('&')
49
+ path = "#{path}?#{sorted}"
50
+ end
51
+ end
52
+
53
+ @client.track(
54
+ 'method' => env['REQUEST_METHOD'] || 'GET',
55
+ 'path' => path,
56
+ 'status_code' => status.to_i,
57
+ 'response_time_ms' => elapsed_ms.round(2),
58
+ 'request_size' => request_size(env),
59
+ 'response_size' => response_size,
60
+ 'consumer_id' => consumer_id
61
+ )
62
+ rescue StandardError
63
+ # Never crash the app
64
+ end
65
+
66
+ [status, headers, body]
67
+ rescue StandardError => e
68
+ # If the app raises, still try to track
69
+ begin
70
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
71
+ consumer_id = identify_consumer(env)
72
+ path = env['PATH_INFO'] || '/'
73
+ if @client.collect_query_string
74
+ qs = env['QUERY_STRING'].to_s
75
+ unless qs.empty?
76
+ sorted = qs.split('&').sort.join('&')
77
+ path = "#{path}?#{sorted}"
78
+ end
79
+ end
80
+
81
+ @client.track(
82
+ 'method' => env['REQUEST_METHOD'] || 'GET',
83
+ 'path' => path,
84
+ 'status_code' => 500,
85
+ 'response_time_ms' => elapsed_ms.round(2),
86
+ 'request_size' => request_size(env),
87
+ 'response_size' => 0,
88
+ 'consumer_id' => consumer_id
89
+ )
90
+ rescue StandardError
91
+ # Never crash
92
+ end
93
+
94
+ raise e
95
+ end
96
+
97
+ private
98
+
99
+ def identify_consumer(env)
100
+ headers = extract_headers(env)
101
+ if @client.identify_consumer
102
+ @client.identify_consumer.call(headers)
103
+ else
104
+ PeekApi::Consumer.default_identify_consumer(headers)
105
+ end
106
+ end
107
+
108
+ def extract_headers(env)
109
+ headers = {}
110
+ env.each do |key, value|
111
+ next unless key.start_with?('HTTP_')
112
+
113
+ header_name = key[5..].downcase.tr('_', '-')
114
+ headers[header_name] = value
115
+ end
116
+ headers
117
+ end
118
+
119
+ def request_size(env)
120
+ env['CONTENT_LENGTH'].to_i
121
+ rescue StandardError
122
+ 0
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PeekApi
4
+ class Railtie < Rails::Railtie
5
+ initializer 'peekapi.configure_middleware' do |app|
6
+ api_key = ENV.fetch('PEEKAPI_API_KEY', nil)
7
+ endpoint = ENV.fetch('PEEKAPI_ENDPOINT', nil)
8
+
9
+ if api_key && !api_key.empty? && endpoint && !endpoint.empty?
10
+ client = PeekApi::Client.new(api_key: api_key, endpoint: endpoint)
11
+ app.middleware.use PeekApi::Middleware::Rack, client: client
12
+
13
+ at_exit { client.shutdown }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'uri'
5
+
6
+ module PeekApi
7
+ module SSRF
8
+ # Private/reserved IPv4 networks
9
+ PRIVATE_NETWORKS = [
10
+ IPAddr.new('10.0.0.0/8'),
11
+ IPAddr.new('172.16.0.0/12'),
12
+ IPAddr.new('192.168.0.0/16'),
13
+ IPAddr.new('100.64.0.0/10'), # CGNAT
14
+ IPAddr.new('127.0.0.0/8'), # Loopback
15
+ IPAddr.new('169.254.0.0/16'), # Link-local
16
+ IPAddr.new('0.0.0.0/8') # "This" network
17
+ ].freeze
18
+
19
+ # Private/reserved IPv6 networks
20
+ PRIVATE_NETWORKS_V6 = [
21
+ IPAddr.new('::1/128'), # Loopback
22
+ IPAddr.new('fe80::/10'), # Link-local
23
+ IPAddr.new('fc00::/7'), # ULA
24
+ IPAddr.new('::ffff:0:0/96') # IPv4-mapped (checked individually below)
25
+ ].freeze
26
+
27
+ module_function
28
+
29
+ # Check if a hostname/IP is a private or reserved address.
30
+ #
31
+ # Covers: RFC 1918, CGNAT (100.64/10), loopback, link-local,
32
+ # IPv6 ULA/link-local, IPv4-mapped IPv6.
33
+ def private_ip?(host)
34
+ addr = IPAddr.new(host)
35
+
36
+ if addr.ipv6?
37
+ # Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
38
+ if addr.ipv4_mapped?
39
+ mapped = addr.native
40
+ return PRIVATE_NETWORKS.any? { |net| net.include?(mapped) }
41
+ end
42
+ return PRIVATE_NETWORKS_V6.any? { |net| net.include?(addr) }
43
+ end
44
+
45
+ PRIVATE_NETWORKS.any? { |net| net.include?(addr) }
46
+ rescue IPAddr::InvalidAddressError
47
+ false
48
+ end
49
+
50
+ # Validate and normalize the ingestion endpoint URL.
51
+ #
52
+ # Raises ArgumentError for:
53
+ # - Non-HTTPS URLs (except localhost)
54
+ # - Private/reserved IP addresses (SSRF protection)
55
+ # - Embedded credentials in URL
56
+ # - Malformed URLs
57
+ def validate_endpoint!(endpoint)
58
+ raise ArgumentError, 'endpoint is required' if endpoint.nil? || endpoint.empty?
59
+
60
+ uri = URI.parse(endpoint)
61
+ raise ArgumentError, "Invalid endpoint URL: #{endpoint}" unless uri.is_a?(URI::HTTP) && uri.host
62
+
63
+ hostname = uri.host.downcase
64
+
65
+ is_localhost = %w[localhost 127.0.0.1 ::1].include?(hostname)
66
+
67
+ unless uri.scheme == 'https' || is_localhost
68
+ raise ArgumentError, "HTTPS required for non-localhost endpoint: #{endpoint}"
69
+ end
70
+
71
+ raise ArgumentError, 'Endpoint URL must not contain credentials' if uri.user || uri.password
72
+
73
+ if !is_localhost && private_ip?(hostname)
74
+ raise ArgumentError, "Endpoint resolves to private/reserved IP: #{hostname}"
75
+ end
76
+
77
+ endpoint
78
+ rescue URI::InvalidURIError
79
+ raise ArgumentError, "Invalid endpoint URL: #{endpoint}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PeekApi
4
+ VERSION = '0.1.0'
5
+ end
data/lib/peekapi.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'peekapi/version'
4
+ require_relative 'peekapi/consumer'
5
+ require_relative 'peekapi/ssrf'
6
+ require_relative 'peekapi/client'
7
+ require_relative 'peekapi/middleware/rack'
8
+
9
+ # Load Railtie only when Rails is present
10
+ require_relative 'peekapi/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: peekapi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - PeekAPI
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webrick
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
55
+ description: Rack middleware and client for tracking API usage analytics. Works with
56
+ Rails, Sinatra, Hanami, and any Rack-compatible framework.
57
+ email:
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/peekapi.rb
65
+ - lib/peekapi/client.rb
66
+ - lib/peekapi/consumer.rb
67
+ - lib/peekapi/middleware/rack.rb
68
+ - lib/peekapi/railtie.rb
69
+ - lib/peekapi/ssrf.rb
70
+ - lib/peekapi/version.rb
71
+ homepage: https://github.com/peekapi-dev/sdk-ruby
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/peekapi-dev/sdk-ruby
76
+ source_code_uri: https://github.com/peekapi-dev/sdk-ruby
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.5.23
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Zero-dependency Ruby SDK for PeekAPI
97
+ test_files: []