levo_rails_middleware 0.1.1
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 +173 -0
- data/lib/levo_rails_middleware/configuration.rb +38 -0
- data/lib/levo_rails_middleware/entry.rb +115 -0
- data/lib/levo_rails_middleware/middleware.rb +66 -0
- data/lib/levo_rails_middleware/sender.rb +194 -0
- data/lib/levo_rails_middleware/version.rb +5 -0
- data/lib/levo_rails_middleware.rb +37 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d6f38f672ca75adc4ccb2104c649e20f91866d6965cedc582c21ba5d1388221c
|
4
|
+
data.tar.gz: f0a9fbd1c9dd64ef5a6a11122861cb6ebce9070206f6247bdca3bc2894fb3ff8
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 04ac860aae7b6d04f95123988d855d5af0067dbff89e9d29da58010dd3b894d2e8607d0779d553e6a29bdc758a472aed5b9e1f0a66d768b671ffb53e6bcbb658
|
7
|
+
data.tar.gz: 0cbab4426082bc1eb894472667e184f46274fc58014efad51d737f58278fea9f84cfb92e451f7e678a09c2d9871a99b7b2a1a9979eae245bc6fcc587c70b4477
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Levo Inc
|
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,173 @@
|
|
1
|
+
|
2
|
+
# Levo Rails Traffic Middleware
|
3
|
+
|
4
|
+
[](https://badge.fury.io/rb/levo-rails-traffic-middleware)
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
6
|
+
|
7
|
+
A lightweight, high-performance middleware for mirroring your Rails application API traffic to the Levo.ai platform for API security analysis.
|
8
|
+
|
9
|
+
## Overview
|
10
|
+
|
11
|
+
The Levo Rails Traffic Middleware captures HTTP requests and responses from your Rails application and securely sends them to the Levo.ai platform. This enables Levo.ai to provide API security analysis, identify vulnerabilities, and help protect your applications without requiring code changes or impacting performance.
|
12
|
+
|
13
|
+
Key features:
|
14
|
+
- **Zero-impact traffic mirroring**: Asynchronously sends data without affecting your application's response time
|
15
|
+
- **Configurable sampling rate**: Control how much traffic is mirrored
|
16
|
+
- **Sensitive data filtering**: Automatically filter confidential information
|
17
|
+
- **Path exclusion**: Skip static assets and health check endpoints
|
18
|
+
- **Size limits**: Prevent excessive data transmission for large payloads
|
19
|
+
- **Production-ready**: Built for high-throughput environments
|
20
|
+
|
21
|
+
## Requirements
|
22
|
+
|
23
|
+
- Ruby 2.6 or later
|
24
|
+
- Rails 5.0 or later
|
25
|
+
|
26
|
+
## Installation
|
27
|
+
|
28
|
+
Add this line to your application's Gemfile:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
gem 'levo_rails_middleware', git: 'https://github.com/levoai/levo-rails-traffic-middleware.git'
|
32
|
+
```
|
33
|
+
|
34
|
+
Then execute:
|
35
|
+
|
36
|
+
```bash
|
37
|
+
$ bundle update
|
38
|
+
```
|
39
|
+
|
40
|
+
## Quick Start
|
41
|
+
|
42
|
+
After installing the gem, you need to:
|
43
|
+
|
44
|
+
1. Configure the middleware
|
45
|
+
2. Add it to your Rails application
|
46
|
+
|
47
|
+
### Configuration
|
48
|
+
|
49
|
+
Create an initializer file at `config/initializers/levo_middleware.rb`:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
require 'levo_rails_middleware'
|
53
|
+
|
54
|
+
LevoRailsMiddleware.configure do |config|
|
55
|
+
# Required: URL for the Levo.ai traffic collector
|
56
|
+
config.remote_url = ENV['LEVO_SATELLITE_URL']
|
57
|
+
|
58
|
+
# Optional configuration with defaults shown
|
59
|
+
config.sampling_rate = 1.0 # 100% of traffic
|
60
|
+
config.exclude_paths = ['/assets/', '/packs/', '/health']
|
61
|
+
config.filter_params = ['password', 'token', 'api_key', 'secret']
|
62
|
+
config.size_threshold_kb = 1024 # Skip bodies larger than 1MB config.timeout_seconds = 3
|
63
|
+
config.enabled = true
|
64
|
+
end
|
65
|
+
|
66
|
+
# Add the middleware to the Rails stack
|
67
|
+
LevoRailsmiddleware.instrument(Rails.application.config)
|
68
|
+
```
|
69
|
+
|
70
|
+
### Adding the Middleware
|
71
|
+
|
72
|
+
In your `config/application.rb` file, add:
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
module YourApp
|
76
|
+
class Application < Rails::Application
|
77
|
+
# ... other configurations ...
|
78
|
+
|
79
|
+
# Add the Levo.ai traffic mirroring middleware
|
80
|
+
require 'levo_rails_middleware'
|
81
|
+
LevoRailsmiddleware.instrument(config)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
```
|
85
|
+
|
86
|
+
## Heroku Deployment
|
87
|
+
|
88
|
+
For Heroku applications, you'll need to set the environment variable for the Levo middleware URL:
|
89
|
+
|
90
|
+
```bash
|
91
|
+
heroku config:set LEVO_SATELLITE_URL='https://collector.levo.ai (Replace with your own Satellite url'
|
92
|
+
heroku config:set LEVOAI_ORG_ID='your-org-id'
|
93
|
+
heroku config:set LEVO_ENV='your-environment-name, like Production or Staging'
|
94
|
+
|
95
|
+
```
|
96
|
+
|
97
|
+
## Configuration Options
|
98
|
+
|
99
|
+
| Option | Description | Default |
|
100
|
+
| ------ | ----------- | ------- |
|
101
|
+
| `remote_url` | The URL to send mirrored traffic to | `ENV['LEVO_SATELLITE_URL']` |
|
102
|
+
| `sampling_rate` | Percentage of requests to mirror (0.0 to 1.0) | `1.0` (100%) |
|
103
|
+
| `exclude_paths` | Array of path prefixes to exclude from mirroring | `['/assets/', '/packs/', '/health']` |
|
104
|
+
| `filter_params` | Array of parameter names to filter (sensitive data) | `['password', 'token', 'api_key', 'secret']` |
|
105
|
+
| `size_threshold_kb` | Maximum size (KB) for request/response bodies | `1024` (1MB) |
|
106
|
+
| `timeout_seconds` | Timeout for sending data to Levo.ai | `3` |
|
107
|
+
| `enabled` | Toggle to enable/disable the middleware | `true` |
|
108
|
+
|
109
|
+
## Advanced Usage
|
110
|
+
|
111
|
+
### Conditional Enabling
|
112
|
+
|
113
|
+
You may want to enable the middleware only in certain environments:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
# In config/initializers/levo_middleware.rb
|
117
|
+
LevoRailsMiddleware.configure do |config|
|
118
|
+
config.remote_url = ENV['LEVO_SATELLITE_URL']
|
119
|
+
config.enabled = Rails.env.production? || Rails.env.staging?
|
120
|
+
end
|
121
|
+
```
|
122
|
+
|
123
|
+
### Custom Parameter Filtering
|
124
|
+
|
125
|
+
You can specify additional sensitive parameters to filter:
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
LevoRailsMiddleware.configure do |config|
|
129
|
+
config.filter_params = ['password', 'token', 'api_key', 'secret', 'ssn', 'credit_card']
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
### Traffic Sampling
|
134
|
+
|
135
|
+
For high-traffic applications, you can reduce the sampling rate:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
LevoRailsMiddleware.configure do |config|
|
139
|
+
# Mirror only 10% of traffic
|
140
|
+
config.sampling_rate = 0.1
|
141
|
+
end
|
142
|
+
```
|
143
|
+
|
144
|
+
## Troubleshooting
|
145
|
+
|
146
|
+
### Verifying Installation
|
147
|
+
|
148
|
+
To verify the middleware is properly installed, check your logs for entries containing `LEVO_MIRROR` when your application receives traffic.
|
149
|
+
|
150
|
+
### Common Issues
|
151
|
+
|
152
|
+
**No data appearing in Levo.ai dashboard**
|
153
|
+
|
154
|
+
1. Verify the `LEVO_SATELLITE_URL` is correct
|
155
|
+
2. Check your application logs for any errors containing `LEVO_MIRROR`
|
156
|
+
3. Ensure the middleware is enabled and the sampling rate is > 0
|
157
|
+
4. Confirm your network allows outbound connections to the Levo.ai service
|
158
|
+
|
159
|
+
**Performance Impact**
|
160
|
+
|
161
|
+
The middleware is designed to have minimal impact on your application's performance. If you notice any impact:
|
162
|
+
|
163
|
+
1. Lower the sampling rate to reduce the number of mirrored requests
|
164
|
+
2. Increase the `exclude_paths` list to skip more endpoints
|
165
|
+
3. Reduce the `size_threshold_kb` to skip large payloads
|
166
|
+
|
167
|
+
## Support
|
168
|
+
|
169
|
+
For questions or issues, contact Levo.ai support at support@levo.ai or visit [help.levo.ai](https://help.levo.ai).
|
170
|
+
|
171
|
+
## License
|
172
|
+
|
173
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LevoRailsmiddleware
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :remote_url, :sampling_rate, :exclude_paths, :filter_params
|
6
|
+
attr_accessor :size_threshold_kb, :timeout_seconds, :enabled
|
7
|
+
attr_accessor :organization_id, :environment_name, :service_name, :host_name
|
8
|
+
attr_accessor :max_retries
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@remote_url = ENV['LEVO_SATELLITE_URL'] || 'https://collector.levo.ai'
|
12
|
+
@sampling_rate = 1.0 # 100% by default
|
13
|
+
@exclude_paths = ['/assets/', '/packs/', '/health']
|
14
|
+
@filter_params = ['password', 'token', 'api_key', 'secret']
|
15
|
+
@size_threshold_kb = 1024 # Skip bodies larger than 1MB
|
16
|
+
@timeout_seconds = 3
|
17
|
+
@enabled = true
|
18
|
+
|
19
|
+
# Levo Satellite specific configuration
|
20
|
+
@organization_id = ENV['LEVOAI_ORG_ID']
|
21
|
+
@environment_name = ENV['LEVO_ENV']
|
22
|
+
@service_name = ENV['LEVO_SERVICE_NAME'] || 'rails-application'
|
23
|
+
@host_name = get_hostname
|
24
|
+
@max_retries = 3
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def get_hostname
|
30
|
+
# Try to get the hostname, fallback to 'unknown-host'
|
31
|
+
begin
|
32
|
+
Socket.gethostname
|
33
|
+
rescue
|
34
|
+
'unknown-host'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module LevoRailsmiddleware
|
5
|
+
class Entry
|
6
|
+
attr_reader :timestamp, :duration_ms, :request, :response
|
7
|
+
|
8
|
+
def initialize(start_time, duration_ms, env, status, headers, body)
|
9
|
+
@timestamp = start_time
|
10
|
+
@duration_ms = duration_ms
|
11
|
+
|
12
|
+
# Create Rack request object
|
13
|
+
rack_request = Rack::Request.new(env)
|
14
|
+
|
15
|
+
# Extract request data
|
16
|
+
@request = {
|
17
|
+
method: rack_request.request_method,
|
18
|
+
path: rack_request.path,
|
19
|
+
query_string: rack_request.query_string,
|
20
|
+
headers: extract_headers(env),
|
21
|
+
body: extract_body(rack_request.body),
|
22
|
+
remote_ip: rack_request.ip,
|
23
|
+
request_id: env['HTTP_X_REQUEST_ID'] || env['action_dispatch.request_id']
|
24
|
+
}
|
25
|
+
|
26
|
+
# Extract response data
|
27
|
+
response_body = extract_response_body(body)
|
28
|
+
|
29
|
+
@response = {
|
30
|
+
status: status,
|
31
|
+
headers: headers_to_hash(headers),
|
32
|
+
body: response_body,
|
33
|
+
size: response_body.bytesize
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_json
|
38
|
+
{
|
39
|
+
timestamp: @timestamp.iso8601,
|
40
|
+
duration_ms: @duration_ms,
|
41
|
+
request: @request,
|
42
|
+
response: @response,
|
43
|
+
environment: Rails.env
|
44
|
+
}.to_json
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def extract_headers(env)
|
50
|
+
headers = {}
|
51
|
+
env.each do |key, value|
|
52
|
+
if key.start_with?('HTTP_')
|
53
|
+
header_name = key[5..-1].gsub('_', '-').downcase
|
54
|
+
headers[header_name] = value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
headers
|
58
|
+
end
|
59
|
+
|
60
|
+
def extract_body(body)
|
61
|
+
return "".dup unless body # Return unfrozen empty string
|
62
|
+
|
63
|
+
body.rewind
|
64
|
+
content = body.read.to_s.dup # Force string conversion and unfreeze
|
65
|
+
body.rewind
|
66
|
+
|
67
|
+
# Check size threshold
|
68
|
+
if content.bytesize > LevoRailsmiddleware.configuration.size_threshold_kb * 1024
|
69
|
+
"[CONTENT TOO LARGE]"
|
70
|
+
else
|
71
|
+
filter_sensitive_data(content)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def extract_response_body(body)
|
76
|
+
# Start with unfrozen empty string
|
77
|
+
content = "".dup
|
78
|
+
|
79
|
+
# Safely collect response body parts
|
80
|
+
if body.respond_to?(:each)
|
81
|
+
body.each do |part|
|
82
|
+
# Create a duplicate of the string to avoid frozen string issues
|
83
|
+
content << part.to_s.dup
|
84
|
+
end
|
85
|
+
else
|
86
|
+
# Handle case where body might be a string or other object
|
87
|
+
content = body.to_s.dup
|
88
|
+
end
|
89
|
+
|
90
|
+
if content.bytesize > LevoRailsmiddleware.configuration.size_threshold_kb * 1024
|
91
|
+
"[CONTENT TOO LARGE]"
|
92
|
+
else
|
93
|
+
filter_sensitive_data(content)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def headers_to_hash(headers)
|
98
|
+
hash = {}
|
99
|
+
headers.each do |key, value|
|
100
|
+
hash[key.to_s] = value
|
101
|
+
end
|
102
|
+
hash
|
103
|
+
end
|
104
|
+
|
105
|
+
def filter_sensitive_data(content)
|
106
|
+
# Start with fresh unfrozen copy
|
107
|
+
filtered = content.to_s.dup
|
108
|
+
LevoRailsmiddleware.configuration.filter_params.each do |param|
|
109
|
+
# Simple regex to find and replace sensitive data
|
110
|
+
filtered.gsub!(/["']?#{param}["']?\s*[=:]\s*["']?[^"' &,\}]+["']?/, "#{param}=[FILTERED]")
|
111
|
+
end
|
112
|
+
filtered
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LevoRailsmiddleware
|
4
|
+
class Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
@sender = Sender.new(LevoRailsmiddleware.configuration.remote_url)
|
8
|
+
rescue => e
|
9
|
+
LevoRailsmiddleware.log_exception("initializing middleware", e)
|
10
|
+
@initialization_failed = true
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(env)
|
14
|
+
# Skip processing if initialization failed or middleware is disabled
|
15
|
+
return @app.call(env) if @initialization_failed || !LevoRailsmiddleware.configuration.enabled
|
16
|
+
|
17
|
+
# Skip excluded paths
|
18
|
+
path = env['PATH_INFO'] || ""
|
19
|
+
return @app.call(env) if should_skip?(path)
|
20
|
+
|
21
|
+
# Skip based on sampling rate
|
22
|
+
return @app.call(env) if rand > LevoRailsmiddleware.configuration.sampling_rate
|
23
|
+
|
24
|
+
start_time = Time.now
|
25
|
+
|
26
|
+
# Preserve the original request body
|
27
|
+
request_body = env['rack.input'].read
|
28
|
+
env['rack.input'].rewind
|
29
|
+
|
30
|
+
# Process the request through the app
|
31
|
+
status, headers, body = @app.call(env)
|
32
|
+
end_time = Time.now
|
33
|
+
|
34
|
+
# Calculate the request duration
|
35
|
+
duration_ms = ((end_time.to_f - start_time.to_f) * 1000).round
|
36
|
+
|
37
|
+
# Save the original input stream
|
38
|
+
saved_input = env['rack.input']
|
39
|
+
env['rack.input'] = StringIO.new(request_body)
|
40
|
+
|
41
|
+
# Create the request/response entry and send it
|
42
|
+
begin
|
43
|
+
entry = Entry.new(start_time, duration_ms, env, status, headers, body)
|
44
|
+
@sender.send_async(entry)
|
45
|
+
rescue => e
|
46
|
+
LevoRailsmiddleware.log_exception("processing request", e)
|
47
|
+
ensure
|
48
|
+
# Restore the original input stream
|
49
|
+
env['rack.input'] = saved_input
|
50
|
+
end
|
51
|
+
|
52
|
+
[status, headers, body]
|
53
|
+
rescue => e
|
54
|
+
LevoRailsmiddleware.log_exception("middleware execution", e)
|
55
|
+
@app.call(env) # Ensure we still call the app even if our middleware fails
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def should_skip?(path)
|
61
|
+
LevoRailsmiddleware.configuration.exclude_paths.any? do |excluded|
|
62
|
+
path.start_with?(excluded)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'net/http'
|
3
|
+
require 'uri'
|
4
|
+
require 'json'
|
5
|
+
require 'securerandom'
|
6
|
+
|
7
|
+
module LevoRailsmiddleware
|
8
|
+
class Sender
|
9
|
+
API_ENDPOINT = "/1.0/ebpf/traces"
|
10
|
+
|
11
|
+
def initialize(remote_url)
|
12
|
+
@remote_url = remote_url
|
13
|
+
@uri = URI.parse(remote_url) if remote_url
|
14
|
+
Rails.logger.info "LEVO_MIRROR: Initialized with URL #{@remote_url}" if defined?(Rails)
|
15
|
+
end
|
16
|
+
|
17
|
+
def send_async(entry)
|
18
|
+
return if @remote_url.nil?
|
19
|
+
|
20
|
+
# Use a separate thread to avoid blocking the request
|
21
|
+
Thread.new do
|
22
|
+
begin
|
23
|
+
send_data(entry)
|
24
|
+
rescue => e
|
25
|
+
LevoRailsmiddleware.log_exception("sending data", e)
|
26
|
+
ensure
|
27
|
+
# Ensure database connection is released
|
28
|
+
ActiveRecord::Base.connection_pool.release_connection if defined?(ActiveRecord)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def send_data(entry)
|
36
|
+
# Convert entry to Levo Satellite format
|
37
|
+
trace = convert_to_satellite_format(entry)
|
38
|
+
json_data = JSON.generate([trace])
|
39
|
+
|
40
|
+
Rails.logger.debug "LEVO_MIRROR: JSON payload preview (first 200 chars): #{json_data[0..200]}..." if defined?(Rails)
|
41
|
+
|
42
|
+
# Create HTTP client
|
43
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
44
|
+
|
45
|
+
# Configure HTTPS if needed
|
46
|
+
if @uri.scheme == 'https'
|
47
|
+
http.use_ssl = true
|
48
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
49
|
+
end
|
50
|
+
|
51
|
+
# Set timeouts
|
52
|
+
timeout = LevoRailsmiddleware.configuration.timeout_seconds
|
53
|
+
http.open_timeout = timeout
|
54
|
+
http.read_timeout = timeout
|
55
|
+
|
56
|
+
# Construct API path correctly
|
57
|
+
endpoint_path = API_ENDPOINT
|
58
|
+
base_path = @uri.path.to_s
|
59
|
+
|
60
|
+
# Ensure the path is properly formed
|
61
|
+
if base_path.empty?
|
62
|
+
full_path = endpoint_path
|
63
|
+
else
|
64
|
+
# Handle trailing slashes correctly
|
65
|
+
if base_path.end_with?('/') && endpoint_path.start_with?('/')
|
66
|
+
full_path = base_path + endpoint_path[1..-1]
|
67
|
+
elsif !base_path.end_with?('/') && !endpoint_path.start_with?('/')
|
68
|
+
full_path = "#{base_path}/#{endpoint_path}"
|
69
|
+
else
|
70
|
+
full_path = base_path + endpoint_path
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Create request with the properly formed path
|
75
|
+
request = Net::HTTP::Post.new(full_path)
|
76
|
+
request.content_type = 'application/json'
|
77
|
+
|
78
|
+
# Add Levo organization ID header
|
79
|
+
if LevoRailsmiddleware.configuration.organization_id
|
80
|
+
request['x-levo-organization-id'] = LevoRailsmiddleware.configuration.organization_id
|
81
|
+
end
|
82
|
+
|
83
|
+
# Add request ID header if available
|
84
|
+
request_id = entry.request[:request_id]
|
85
|
+
request['X-Request-ID'] = request_id if request_id
|
86
|
+
|
87
|
+
# Set the request body
|
88
|
+
request.body = json_data
|
89
|
+
|
90
|
+
# Send request with retry logic
|
91
|
+
response = nil
|
92
|
+
success = false
|
93
|
+
|
94
|
+
# Retry logic
|
95
|
+
max_retries = LevoRailsmiddleware.configuration.max_retries
|
96
|
+
|
97
|
+
for attempt in 0...max_retries
|
98
|
+
if attempt > 0
|
99
|
+
# Exponential backoff with jitter
|
100
|
+
backoff_ms = (2 ** attempt) * 100 + rand(1000)
|
101
|
+
sleep(backoff_ms / 1000.0)
|
102
|
+
Rails.logger.info "LEVO_MIRROR: Retry attempt #{attempt+1} for sending trace data" if defined?(Rails)
|
103
|
+
end
|
104
|
+
|
105
|
+
begin
|
106
|
+
response = http.request(request)
|
107
|
+
|
108
|
+
# Check status code
|
109
|
+
if response.code.to_i >= 200 && response.code.to_i < 300
|
110
|
+
success = true
|
111
|
+
break
|
112
|
+
else
|
113
|
+
Rails.logger.error "LEVO_MIRROR: Failed to send data. Status: #{response.code}" if defined?(Rails)
|
114
|
+
end
|
115
|
+
rescue => e
|
116
|
+
Rails.logger.error "LEVO_MIRROR: Error during HTTP request: #{e.message}" if defined?(Rails)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
if success
|
121
|
+
Rails.logger.info "LEVO_MIRROR: Successfully sent trace data, status: #{response.code}" if defined?(Rails)
|
122
|
+
else
|
123
|
+
Rails.logger.error "LEVO_MIRROR: Failed to send trace after #{max_retries} attempts" if defined?(Rails)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def convert_to_satellite_format(entry)
|
128
|
+
# Generate trace and span IDs similar to the C++ implementation
|
129
|
+
trace_id = SecureRandom.uuid
|
130
|
+
span_id = SecureRandom.uuid
|
131
|
+
|
132
|
+
# Calculate duration in nanoseconds
|
133
|
+
duration_ns = entry.duration_ms * 1_000_000
|
134
|
+
|
135
|
+
# Get current time in nanoseconds
|
136
|
+
request_time_ns = (Time.now.to_f * 1_000_000_000).to_i
|
137
|
+
|
138
|
+
# Extract request method and path
|
139
|
+
method = entry.request[:method]
|
140
|
+
path = entry.request[:path]
|
141
|
+
|
142
|
+
# Convert headers to the expected format
|
143
|
+
request_headers = {}
|
144
|
+
entry.request[:headers].each do |name, value|
|
145
|
+
request_headers[name.downcase] = value
|
146
|
+
end
|
147
|
+
|
148
|
+
# Add special headers that the backend expects
|
149
|
+
request_headers[":path"] = path
|
150
|
+
request_headers[":method"] = method
|
151
|
+
request_headers[":authority"] = request_headers["host"] if request_headers["host"]
|
152
|
+
|
153
|
+
response_headers = {}
|
154
|
+
entry.response[:headers].each do |name, value|
|
155
|
+
response_headers[name.downcase] = value
|
156
|
+
end
|
157
|
+
|
158
|
+
# Add special response headers
|
159
|
+
response_headers[":status"] = entry.response[:status].to_s
|
160
|
+
|
161
|
+
# Build the trace structure
|
162
|
+
{
|
163
|
+
"http_scheme" => (request_headers['x-forwarded-proto'] || 'http'),
|
164
|
+
"request" => {
|
165
|
+
"headers" => request_headers,
|
166
|
+
"body" => Base64.strict_encode64(entry.request[:body].to_s),
|
167
|
+
"truncated" => false
|
168
|
+
},
|
169
|
+
"response" => {
|
170
|
+
"headers" => response_headers,
|
171
|
+
"body" => Base64.strict_encode64(entry.response[:body].to_s),
|
172
|
+
"truncated" => false,
|
173
|
+
"status_code" => entry.response[:status]
|
174
|
+
},
|
175
|
+
"resource" => {
|
176
|
+
"service_name" => LevoRailsmiddleware.configuration.service_name,
|
177
|
+
"host_name" => LevoRailsmiddleware.configuration.host_name,
|
178
|
+
"telemetry_sdk_language" => "ruby",
|
179
|
+
"telemetry_sdk_name" => "levo_rails_middleware",
|
180
|
+
"telemetry_sdk_version" => LevoRailsmiddleware::VERSION,
|
181
|
+
"levo_env" => LevoRailsmiddleware.configuration.environment_name
|
182
|
+
},
|
183
|
+
"duration_ns" => duration_ns,
|
184
|
+
"request_time_ns" => request_time_ns,
|
185
|
+
"trace_id" => trace_id,
|
186
|
+
"span_id" => span_id,
|
187
|
+
"span_kind" => "SERVER",
|
188
|
+
"path" => path,
|
189
|
+
"method" => method,
|
190
|
+
"client_ip" => entry.request[:remote_ip]
|
191
|
+
}
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'levo_rails_middleware/version'
|
4
|
+
require 'levo_rails_middleware/configuration'
|
5
|
+
require 'levo_rails_middleware/middleware'
|
6
|
+
require 'levo_rails_middleware/entry'
|
7
|
+
require 'levo_rails_middleware/sender'
|
8
|
+
|
9
|
+
module LevoRailsmiddleware
|
10
|
+
class << self
|
11
|
+
attr_writer :configuration
|
12
|
+
|
13
|
+
# Access the configuration
|
14
|
+
def configuration
|
15
|
+
@configuration ||= Configuration.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Configure the middleware
|
19
|
+
def configure
|
20
|
+
yield(configuration) if block_given?
|
21
|
+
end
|
22
|
+
|
23
|
+
# Add middleware to Rails application
|
24
|
+
def instrument(app_config)
|
25
|
+
# Use the class constant instead of a string
|
26
|
+
app_config.middleware.use LevoRailsmiddleware::Middleware # Changed from insert_before with string
|
27
|
+
end
|
28
|
+
|
29
|
+
# Log exceptions
|
30
|
+
def log_exception(context, exception)
|
31
|
+
Rails.logger.error "LEVO_middleware: Exception while #{context}: #{exception.message} (#{exception.class.name})"
|
32
|
+
exception.backtrace&.each do |line|
|
33
|
+
Rails.logger.error "LEVO_middleware: #{line}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
metadata
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: levo_rails_middleware
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Levo.ai Team
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-05-13 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.6.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.6.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '2.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '2.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '13.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '13.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
description: A Rails middleware for Levo.ai customers that captures HTTP requests
|
70
|
+
and responses and sends them to Levo.ai for API security analysis
|
71
|
+
email:
|
72
|
+
- support@levo.ai
|
73
|
+
executables: []
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- LICENSE
|
78
|
+
- README.md
|
79
|
+
- lib/levo_rails_middleware.rb
|
80
|
+
- lib/levo_rails_middleware/configuration.rb
|
81
|
+
- lib/levo_rails_middleware/entry.rb
|
82
|
+
- lib/levo_rails_middleware/middleware.rb
|
83
|
+
- lib/levo_rails_middleware/sender.rb
|
84
|
+
- lib/levo_rails_middleware/version.rb
|
85
|
+
homepage: https://github.com/levoai/levo-rails-middleware
|
86
|
+
licenses:
|
87
|
+
- MIT
|
88
|
+
metadata: {}
|
89
|
+
post_install_message:
|
90
|
+
rdoc_options: []
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
requirements: []
|
104
|
+
rubygems_version: 3.4.19
|
105
|
+
signing_key:
|
106
|
+
specification_version: 4
|
107
|
+
summary: API traffic middlewareing middleware for Rails applications
|
108
|
+
test_files: []
|