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 +7 -0
- data/LICENSE +21 -0
- data/README.md +152 -0
- data/lib/peekapi/client.rb +455 -0
- data/lib/peekapi/consumer.rb +32 -0
- data/lib/peekapi/middleware/rack.rb +126 -0
- data/lib/peekapi/railtie.rb +17 -0
- data/lib/peekapi/ssrf.rb +82 -0
- data/lib/peekapi/version.rb +5 -0
- data/lib/peekapi.rb +10 -0
- metadata +97 -0
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
|
+
[](https://rubygems.org/gems/peekapi)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](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
|
data/lib/peekapi/ssrf.rb
ADDED
|
@@ -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
|
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: []
|