logtail-rack 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/.github/workflows/main.yml +33 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/Gemfile +3 -0
- data/LICENSE.md +16 -0
- data/README.md +9 -0
- data/Rakefile +6 -0
- data/lib/logtail-rack.rb +16 -0
- data/lib/logtail-rack/config.rb +85 -0
- data/lib/logtail-rack/error_event.rb +28 -0
- data/lib/logtail-rack/http_context.rb +27 -0
- data/lib/logtail-rack/http_events.rb +277 -0
- data/lib/logtail-rack/http_request.rb +37 -0
- data/lib/logtail-rack/http_response.rb +54 -0
- data/lib/logtail-rack/middleware.rb +28 -0
- data/lib/logtail-rack/session_context.rb +47 -0
- data/lib/logtail-rack/user_context.rb +132 -0
- data/lib/logtail-rack/util/request.rb +63 -0
- data/lib/logtail-rack/version.rb +7 -0
- data/logtail-ruby-rack.gemspec +36 -0
- metadata +142 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: cc7cc7670a2f510b98d303a3a8a9d508f12845b3f4aa122a3ba85b74811a2612
|
4
|
+
data.tar.gz: 192cff59004342160a95c620d3c454a99466e7414229fd6530c09a7a588634ad
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 352c298d34087b2465ee410376a2e1f7620617a2a739e92536675f2ad1228019c6964022cb5403935d344130c97080c5f5d705b813dd1abfbdfc07d2ce151526
|
7
|
+
data.tar.gz: 3d31454c761c8b61b54f3bf9f3d0147d886e95361532d03e92ae218b1e06a475916eefd54dc1a1256b10149b9a2d33571f7cffbc4168c562e42f4805c52385d6
|
@@ -0,0 +1,33 @@
|
|
1
|
+
name: build
|
2
|
+
|
3
|
+
on: [push, pull_request]
|
4
|
+
|
5
|
+
jobs:
|
6
|
+
test:
|
7
|
+
|
8
|
+
runs-on: ubuntu-20.04
|
9
|
+
|
10
|
+
strategy:
|
11
|
+
matrix:
|
12
|
+
ruby-version:
|
13
|
+
- 3.0.0
|
14
|
+
- 2.7.2
|
15
|
+
- 2.6.6
|
16
|
+
- 2.5.8
|
17
|
+
- 2.4.10
|
18
|
+
- 2.3.8
|
19
|
+
- 2.2.10
|
20
|
+
- jruby-9.2.14.0
|
21
|
+
- truffleruby-21.0.0
|
22
|
+
|
23
|
+
steps:
|
24
|
+
- uses: actions/checkout@v2
|
25
|
+
|
26
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
27
|
+
uses: ruby/setup-ruby@v1
|
28
|
+
with:
|
29
|
+
ruby-version: ${{ matrix.ruby-version }}
|
30
|
+
bundler-cache: true
|
31
|
+
|
32
|
+
- name: Run tests
|
33
|
+
run: bundle exec rspec --format documentation
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.md
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# License
|
2
|
+
|
3
|
+
Copyright (c) 2021, Logtail
|
4
|
+
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
6
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
7
|
+
and this permission notice appear in all copies.
|
8
|
+
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
11
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
13
|
+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
14
|
+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
15
|
+
THIS SOFTWARE.
|
16
|
+
|
data/README.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
# 🪵 Logtail Integration For Rack
|
2
|
+
|
3
|
+
[](LICENSE.md)
|
4
|
+
[](https://github.com/logtail/logtail-ruby-rack/actions?query=workflow%3Abuild)
|
5
|
+
|
6
|
+
This library integrates the [`logtail` Ruby library](https://github.com/logtail/logtail-ruby) with the [rack](https://github.com/rack/rack) framework,
|
7
|
+
turning your Rack logs into rich structured events.
|
8
|
+
|
9
|
+
* **Sign-up: [https://logtail.com](https://logtail.com)**
|
data/Rakefile
ADDED
data/lib/logtail-rack.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require "rack"
|
2
|
+
require "logtail"
|
3
|
+
require "logtail-rack/config"
|
4
|
+
require "logtail-rack/error_event"
|
5
|
+
require "logtail-rack/http_context"
|
6
|
+
require "logtail-rack/http_events"
|
7
|
+
require "logtail-rack/session_context"
|
8
|
+
require "logtail-rack/user_context"
|
9
|
+
|
10
|
+
module Logtail
|
11
|
+
module Integrations
|
12
|
+
module Rack
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require "logtail"
|
2
|
+
|
3
|
+
Logtail::Config.instance.define_singleton_method(:logrageify!) do
|
4
|
+
integrations.rack.http_events.collapse_into_single_event = true
|
5
|
+
end
|
6
|
+
|
7
|
+
module Logtail
|
8
|
+
class Config
|
9
|
+
module Integrations
|
10
|
+
extend self
|
11
|
+
# Convenience module for accessing the various `Logtail::Integrations::Rack::*` classes
|
12
|
+
# through the {Logtail::Config} object. Logtail couples configuration with the class
|
13
|
+
# responsibls for implementing it. This provides for a tighter design, but also
|
14
|
+
# requires the user to understand and access the various classes. This module aims
|
15
|
+
# to provide a simple ruby-like configuration interface for internal Logtail classes.
|
16
|
+
#
|
17
|
+
# For example:
|
18
|
+
#
|
19
|
+
# config = Logtail::Config.instance
|
20
|
+
# config.integrations.rack.http_events.enabled = false
|
21
|
+
def rack
|
22
|
+
Rack
|
23
|
+
end
|
24
|
+
|
25
|
+
module Rack
|
26
|
+
extend self
|
27
|
+
|
28
|
+
# Convenience method for accessing the {Logtail::Integrations::Rack::ErrorEvent}
|
29
|
+
# middleware class specific configuration. See {Logtail::Integrations::Rack::ExceptionEvent}
|
30
|
+
# for a list of methods available.
|
31
|
+
#
|
32
|
+
# @example
|
33
|
+
# config = Logtail::Config.instance
|
34
|
+
# config.integrations.rack.error_event.enabled = false
|
35
|
+
def error_event
|
36
|
+
Logtail::Integrations::Rack::ErrorEvent
|
37
|
+
end
|
38
|
+
|
39
|
+
# Convenience method for accessing the {Logtail::Integrations::Rack::HTTPContext}
|
40
|
+
# middleware class specific configuration. See {Logtail::Integrations::Rack::HTTPContext}
|
41
|
+
# for a list of methods available.
|
42
|
+
#
|
43
|
+
# @example
|
44
|
+
# config = Logtail::Config.instance
|
45
|
+
# config.integrations.rack.http_context.enabled = false
|
46
|
+
def http_context
|
47
|
+
Logtail::Integrations::Rack::HTTPContext
|
48
|
+
end
|
49
|
+
|
50
|
+
# Convenience method for accessing the {Logtail::Integrations::Rack::HTTPEvents}
|
51
|
+
# middleware class specific configuration. See {Logtail::Integrations::Rack::HTTPEvents}
|
52
|
+
# for a list of methods available.
|
53
|
+
#
|
54
|
+
# @example
|
55
|
+
# config = Logtail::Config.instance
|
56
|
+
# config.integrations.rack.http_events.enabled = false
|
57
|
+
def http_events
|
58
|
+
Logtail::Integrations::Rack::HTTPEvents
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convenience method for accessing the {Logtail::Integrations::Rack::SessionContext}
|
62
|
+
# middleware class specific configuration. See {Logtail::Integrations::Rack::SessionContext}
|
63
|
+
# for a list of methods available.
|
64
|
+
#
|
65
|
+
# @example
|
66
|
+
# config = Logtail::Config.instance
|
67
|
+
# config.integrations.rack.session_context.enabled = false
|
68
|
+
def session_context
|
69
|
+
Logtail::Integrations::Rack::SessionContext
|
70
|
+
end
|
71
|
+
|
72
|
+
# Convenience method for accessing the {Logtail::Integrations::Rack::UserContext}
|
73
|
+
# middleware class specific configuration. See {Logtail::Integrations::Rack::UserContext}
|
74
|
+
# for a list of methods available.
|
75
|
+
#
|
76
|
+
# @example
|
77
|
+
# config = Logtail::Config.instance
|
78
|
+
# config.integrations.rack.user_context.enabled = false
|
79
|
+
def user_context
|
80
|
+
Logtail::Integrations::Rack::UserContext
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "logtail/config"
|
2
|
+
require "logtail/events/error"
|
3
|
+
require "logtail-rack/middleware"
|
4
|
+
|
5
|
+
module Logtail
|
6
|
+
module Integrations
|
7
|
+
module Rack
|
8
|
+
# A Rack middleware that is reponsible for capturing exception and error events
|
9
|
+
class ErrorEvent < Middleware
|
10
|
+
def call(env)
|
11
|
+
begin
|
12
|
+
status, headers, body = @app.call(env)
|
13
|
+
rescue Exception => exception
|
14
|
+
Config.instance.logger.fatal do
|
15
|
+
Events::Error.new(
|
16
|
+
name: exception.class.name,
|
17
|
+
error_message: exception.message,
|
18
|
+
backtrace: exception.backtrace
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
raise exception
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "logtail/contexts/http"
|
2
|
+
require "logtail/current_context"
|
3
|
+
require "logtail-rack/middleware"
|
4
|
+
require "logtail-rack/util/request"
|
5
|
+
|
6
|
+
module Logtail
|
7
|
+
module Integrations
|
8
|
+
module Rack
|
9
|
+
# A Rack middleware that is reponsible for adding the HTTP context {Logtail::Contexts::HTTP}.
|
10
|
+
class HTTPContext < Middleware
|
11
|
+
def call(env)
|
12
|
+
request = Util::Request.new(env)
|
13
|
+
context = Contexts::HTTP.new(
|
14
|
+
host: request.host,
|
15
|
+
method: request.request_method,
|
16
|
+
path: request.path,
|
17
|
+
remote_addr: request.ip,
|
18
|
+
request_id: request.request_id
|
19
|
+
)
|
20
|
+
|
21
|
+
CurrentContext.add(context.to_hash)
|
22
|
+
@app.call(env)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,277 @@
|
|
1
|
+
require "set"
|
2
|
+
|
3
|
+
require "logtail/config"
|
4
|
+
require "logtail/contexts/http"
|
5
|
+
require "logtail/current_context"
|
6
|
+
require "logtail-rack/http_request"
|
7
|
+
require "logtail-rack/http_response"
|
8
|
+
require "logtail-rack/middleware"
|
9
|
+
|
10
|
+
module Logtail
|
11
|
+
module Integrations
|
12
|
+
module Rack
|
13
|
+
# A Rack middleware that is reponsible for capturing and logging HTTP server requests and
|
14
|
+
# response events. The {Events::HTTPRequest} and {Events::HTTPResponse} events
|
15
|
+
# respectively.
|
16
|
+
class HTTPEvents < Middleware
|
17
|
+
class << self
|
18
|
+
# Allows you to capture the HTTP request body, default is off (false).
|
19
|
+
#
|
20
|
+
# Capturing HTTP bodies can be extremely helpful when debugging issues,
|
21
|
+
# but please proceed with caution:
|
22
|
+
#
|
23
|
+
# 1. Capturing HTTP bodies can use quite a bit of data (this can be mitigated, see below)
|
24
|
+
#
|
25
|
+
# If you opt to capture bodies, you can also truncate the size to reduce the data
|
26
|
+
# captured. See {Events::HTTPRequest}.
|
27
|
+
#
|
28
|
+
# @example
|
29
|
+
# Logtail::Integrations::Rack::HTTPEvents.capture_request_body = true
|
30
|
+
def capture_request_body=(value)
|
31
|
+
@capture_request_body = value
|
32
|
+
end
|
33
|
+
|
34
|
+
# Accessor method for {#capture_request_body=}
|
35
|
+
def capture_request_body?
|
36
|
+
@capture_request_body == true
|
37
|
+
end
|
38
|
+
|
39
|
+
# Just like {#capture_request_body=} but for the {Events::HTTPResponse} event.
|
40
|
+
# Please see {#capture_request_body=} for more details. The documentation there also
|
41
|
+
# applies here.
|
42
|
+
def capture_response_body=(value)
|
43
|
+
@capture_response_body = value
|
44
|
+
end
|
45
|
+
|
46
|
+
# Accessor method for {#capture_response_body=}
|
47
|
+
def capture_response_body?
|
48
|
+
@capture_response_body == true
|
49
|
+
end
|
50
|
+
|
51
|
+
# Collapse both the HTTP request and response events into a single log line event.
|
52
|
+
# While we don't recommend this, it can help to reduce log volume if desired.
|
53
|
+
# The reason we don't recommend this, is because the logging service you use should
|
54
|
+
# not be so expensive that you need to strip out useful logs. It should also provide
|
55
|
+
# the tools necessary to properly search your logs and reduce noise. Such as viewing
|
56
|
+
# logs for a specific request.
|
57
|
+
#
|
58
|
+
# To provide an example. This setting turns this:
|
59
|
+
#
|
60
|
+
# Started GET "/" for 127.0.0.1 at 2012-03-10 14:28:14 +0100
|
61
|
+
# Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
|
62
|
+
#
|
63
|
+
# Into this:
|
64
|
+
#
|
65
|
+
# Get "/" sent 200 OK in 79ms
|
66
|
+
#
|
67
|
+
# The single event is still a {Logtail::Events::HTTPResponse} event. Because
|
68
|
+
# we capture HTTP context, you still get the HTTP details, but you will not get
|
69
|
+
# all of the request details that the {Logtail::Events::HTTPRequest} event would
|
70
|
+
# provide.
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
# Logtail::Integrations::Rack::HTTPEvents.collapse_into_single_event = true
|
74
|
+
def collapse_into_single_event=(value)
|
75
|
+
@collapse_into_single_event = value
|
76
|
+
end
|
77
|
+
|
78
|
+
# Accessor method for {#collapse_into_single_event=}.
|
79
|
+
def collapse_into_single_event?
|
80
|
+
@collapse_into_single_event == true
|
81
|
+
end
|
82
|
+
|
83
|
+
# This setting allows you to silence requests based on any conditions you desire.
|
84
|
+
# We require a block because it gives you complete control over how you want to
|
85
|
+
# silence requests. The first parameter being the traditional Rack env hash, the
|
86
|
+
# second being a [Rack Request](http://www.rubydoc.info/gems/rack/Rack/Request) object.
|
87
|
+
#
|
88
|
+
# @example
|
89
|
+
# Integrations::Rack::HTTPEvents.silence_request = lambda do |rack_env, rack_request|
|
90
|
+
# rack_request.path == "/_health"
|
91
|
+
# end
|
92
|
+
def silence_request=(proc)
|
93
|
+
if proc && !proc.is_a?(Proc)
|
94
|
+
raise ArgumentError.new("The value passed to #silence_request must be a Proc")
|
95
|
+
end
|
96
|
+
|
97
|
+
@silence_request = proc
|
98
|
+
end
|
99
|
+
|
100
|
+
# Accessor method for {#silence_request=}
|
101
|
+
def silence_request
|
102
|
+
@silence_request
|
103
|
+
end
|
104
|
+
|
105
|
+
def http_body_limit=(value)
|
106
|
+
@http_body_limit = value
|
107
|
+
end
|
108
|
+
|
109
|
+
# Accessor method for {#http_body_limit=}
|
110
|
+
def http_body_limit
|
111
|
+
@http_body_limit
|
112
|
+
end
|
113
|
+
|
114
|
+
def http_header_filters=(value)
|
115
|
+
@http_header_filters = value
|
116
|
+
end
|
117
|
+
|
118
|
+
# Accessor method for {#http_header_filters=}
|
119
|
+
def http_header_filters
|
120
|
+
@http_header_filters
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
CONTENT_LENGTH_KEY = 'Content-Length'.freeze
|
125
|
+
|
126
|
+
def call(env)
|
127
|
+
request = Util::Request.new(env)
|
128
|
+
|
129
|
+
if silenced?(env, request)
|
130
|
+
if Config.instance.logger.respond_to?(:silence)
|
131
|
+
Config.instance.logger.silence do
|
132
|
+
@app.call(env)
|
133
|
+
end
|
134
|
+
else
|
135
|
+
@app.call(env)
|
136
|
+
end
|
137
|
+
|
138
|
+
elsif collapse_into_single_event?
|
139
|
+
start = Time.now
|
140
|
+
|
141
|
+
status, headers, body = @app.call(env)
|
142
|
+
|
143
|
+
Config.instance.logger.info do
|
144
|
+
http_context = CurrentContext.fetch(:http)
|
145
|
+
content_length = headers[CONTENT_LENGTH_KEY]
|
146
|
+
duration_ms = (Time.now - start) * 1000.0
|
147
|
+
|
148
|
+
http_response = HTTPResponse.new(
|
149
|
+
content_length: content_length,
|
150
|
+
headers: headers,
|
151
|
+
http_context: http_context,
|
152
|
+
request_id: request.request_id,
|
153
|
+
status: status,
|
154
|
+
duration_ms: duration_ms,
|
155
|
+
body_limit: self.class.http_body_limit,
|
156
|
+
headers_to_sanitize: self.class.http_header_filters,
|
157
|
+
)
|
158
|
+
|
159
|
+
{
|
160
|
+
message: http_response.message,
|
161
|
+
event: {
|
162
|
+
http_response_sent: {
|
163
|
+
body: http_response.body,
|
164
|
+
content_length: http_response.content_length,
|
165
|
+
headers_json: http_response.headers_json,
|
166
|
+
request_id: http_response.request_id,
|
167
|
+
service_name: http_response.service_name,
|
168
|
+
status: http_response.status,
|
169
|
+
duration_ms: http_response.duration_ms,
|
170
|
+
}
|
171
|
+
}
|
172
|
+
}
|
173
|
+
end
|
174
|
+
|
175
|
+
[status, headers, body]
|
176
|
+
else
|
177
|
+
start = Time.now
|
178
|
+
|
179
|
+
Config.instance.logger.info do
|
180
|
+
event_body = capture_request_body? ? request.body_content : nil
|
181
|
+
http_request = HTTPRequest.new(
|
182
|
+
body: event_body,
|
183
|
+
content_length: request.content_length,
|
184
|
+
headers: request.headers,
|
185
|
+
host: request.host,
|
186
|
+
method: request.request_method,
|
187
|
+
path: request.path,
|
188
|
+
port: request.port,
|
189
|
+
query_string: request.query_string,
|
190
|
+
request_id: request.request_id,
|
191
|
+
scheme: request.scheme,
|
192
|
+
body_limit: self.class.http_body_limit,
|
193
|
+
headers_to_sanitize: self.class.http_header_filters,
|
194
|
+
)
|
195
|
+
|
196
|
+
{
|
197
|
+
message: http_request.message,
|
198
|
+
event: {
|
199
|
+
http_request_received: {
|
200
|
+
body: http_request.body,
|
201
|
+
content_length: http_request.content_length,
|
202
|
+
headers_json: http_request.headers_json,
|
203
|
+
host: http_request.host,
|
204
|
+
method: http_request.method,
|
205
|
+
path: http_request.path,
|
206
|
+
port: http_request.port,
|
207
|
+
query_string: http_request.query_string,
|
208
|
+
request_id: http_request.request_id,
|
209
|
+
scheme: http_request.scheme,
|
210
|
+
service_name: http_request.service_name,
|
211
|
+
}
|
212
|
+
}
|
213
|
+
}
|
214
|
+
end
|
215
|
+
|
216
|
+
status, headers, body = @app.call(env)
|
217
|
+
|
218
|
+
Config.instance.logger.info do
|
219
|
+
event_body = capture_response_body? ? body : nil
|
220
|
+
content_length = headers[CONTENT_LENGTH_KEY]
|
221
|
+
duration_ms = (Time.now - start) * 1000.0
|
222
|
+
|
223
|
+
http_response = HTTPResponse.new(
|
224
|
+
body: event_body,
|
225
|
+
content_length: content_length,
|
226
|
+
headers: headers,
|
227
|
+
request_id: request.request_id,
|
228
|
+
status: status,
|
229
|
+
duration_ms: duration_ms,
|
230
|
+
body_limit: self.class.http_body_limit,
|
231
|
+
headers_to_sanitize: self.class.http_header_filters,
|
232
|
+
)
|
233
|
+
|
234
|
+
{
|
235
|
+
message: http_response.message,
|
236
|
+
event: {
|
237
|
+
http_response_sent: {
|
238
|
+
body: http_response.body,
|
239
|
+
content_length: http_response.content_length,
|
240
|
+
headers_json: http_response.headers_json,
|
241
|
+
request_id: http_response.request_id,
|
242
|
+
service_name: http_response.service_name,
|
243
|
+
status: http_response.status,
|
244
|
+
duration_ms: http_response.duration_ms,
|
245
|
+
}
|
246
|
+
}
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
[status, headers, body]
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
private
|
255
|
+
def capture_request_body?
|
256
|
+
self.class.capture_request_body?
|
257
|
+
end
|
258
|
+
|
259
|
+
def capture_response_body?
|
260
|
+
self.class.capture_response_body?
|
261
|
+
end
|
262
|
+
|
263
|
+
def collapse_into_single_event?
|
264
|
+
self.class.collapse_into_single_event?
|
265
|
+
end
|
266
|
+
|
267
|
+
def silenced?(env, request)
|
268
|
+
if !self.class.silence_request.nil?
|
269
|
+
self.class.silence_request.call(env, request)
|
270
|
+
else
|
271
|
+
false
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Logtail
|
2
|
+
module Integrations
|
3
|
+
module Rack
|
4
|
+
# The HTTP server request event tracks incoming HTTP requests to your HTTP server.
|
5
|
+
# Such as unicorn, webrick, puma, etc.
|
6
|
+
#
|
7
|
+
# @note This event should be installed automatically through integrations,
|
8
|
+
# such as the {Integrations::ActionController::LogSubscriber} integration.
|
9
|
+
class HTTPRequest
|
10
|
+
attr_reader :body, :content_length, :headers, :headers_json, :host, :method, :path, :port,
|
11
|
+
:query_string, :request_id, :scheme, :service_name
|
12
|
+
|
13
|
+
def initialize(attributes)
|
14
|
+
@body = attributes[:body]
|
15
|
+
@content_length = attributes[:content_length]
|
16
|
+
@headers = attributes[:headers]
|
17
|
+
@host = attributes[:host]
|
18
|
+
@method = attributes[:method]
|
19
|
+
@path = attributes[:path]
|
20
|
+
@port = attributes[:port]
|
21
|
+
@query_string = attributes[:query_string]
|
22
|
+
@scheme = attributes[:scheme]
|
23
|
+
@request_id = attributes[:request_id]
|
24
|
+
@service_name = attributes[:service_name]
|
25
|
+
|
26
|
+
if @headers
|
27
|
+
@headers_json = @headers.to_json
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def message
|
32
|
+
'Started %s "%s"' % [method, path]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Logtail
|
2
|
+
module Integrations
|
3
|
+
module Rack
|
4
|
+
# The HTTP server response event tracks outgoing HTTP responses that you send
|
5
|
+
# to clients.
|
6
|
+
|
7
|
+
class HTTPResponse
|
8
|
+
attr_reader :body, :content_length, :headers, :headers_json, :http_context, :request_id, :service_name,
|
9
|
+
:status, :duration_ms
|
10
|
+
|
11
|
+
def initialize(attributes)
|
12
|
+
@body = attributes[:body]
|
13
|
+
@content_length = attributes[:content_length]
|
14
|
+
@headers = attributes[:headers]
|
15
|
+
@http_context = attributes[:http_context]
|
16
|
+
@request_id = attributes[:request_id]
|
17
|
+
@service_name = attributes[:service_name]
|
18
|
+
@status = attributes[:status]
|
19
|
+
@duration_ms = attributes[:duration_ms]
|
20
|
+
|
21
|
+
if @headers
|
22
|
+
@headers_json = @headers.to_json
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns the human readable log message for this event.
|
27
|
+
def message
|
28
|
+
if http_context
|
29
|
+
message = "#{http_context[:method]} #{http_context[:path]} completed with " \
|
30
|
+
"#{status} #{status_description} "
|
31
|
+
|
32
|
+
if content_length
|
33
|
+
message << ", #{content_length} bytes, "
|
34
|
+
end
|
35
|
+
|
36
|
+
message << "in #{duration_ms}ms"
|
37
|
+
else
|
38
|
+
message = "Completed #{status} #{status_description} "
|
39
|
+
|
40
|
+
if content_length
|
41
|
+
message << ", #{content_length} bytes, "
|
42
|
+
end
|
43
|
+
|
44
|
+
message << "in #{duration_ms}ms"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def status_description
|
49
|
+
::Rack::Utils::HTTP_STATUS_CODES[status]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Logtail
|
2
|
+
module Integrations
|
3
|
+
module Rack
|
4
|
+
# Base class that all Logtail Rack middlewares extend. See the class level methods for
|
5
|
+
# configuration options.
|
6
|
+
class Middleware
|
7
|
+
class << self
|
8
|
+
# Easily enable / disable specific middlewares.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# Logtail::Integrations::Rack::UserContext.enabled = false
|
12
|
+
def enabled=(value)
|
13
|
+
@enabled = value
|
14
|
+
end
|
15
|
+
|
16
|
+
# Accessor method for {#enabled=}.
|
17
|
+
def enabled?
|
18
|
+
@enabled != false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(app)
|
23
|
+
@app = app
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "logtail/config"
|
2
|
+
require "logtail/contexts/session"
|
3
|
+
require "logtail-rack/middleware"
|
4
|
+
|
5
|
+
module Logtail
|
6
|
+
module Integrations
|
7
|
+
module Rack
|
8
|
+
# A Rack middleware that is responsible for adding the Session context
|
9
|
+
# {Logtail::Contexts::Session}.
|
10
|
+
class SessionContext < Middleware
|
11
|
+
def call(env)
|
12
|
+
id = get_session_id(env)
|
13
|
+
if id
|
14
|
+
context = Contexts::Session.new(id: id)
|
15
|
+
CurrentContext.with(context) do
|
16
|
+
@app.call(env)
|
17
|
+
end
|
18
|
+
else
|
19
|
+
@app.call(env)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
def get_session_id(env)
|
25
|
+
if session = env['rack.session']
|
26
|
+
if session.respond_to?(:id)
|
27
|
+
Logtail::Config.instance.debug { "Rack env session detected, using id attribute" }
|
28
|
+
session.id
|
29
|
+
elsif session.respond_to?(:[])
|
30
|
+
Logtail::Config.instance.debug { "Rack env session detected, using the session_id key" }
|
31
|
+
session["session_id"]
|
32
|
+
else
|
33
|
+
Logtail::Config.instance.debug { "Rack env session detected but could not extract id" }
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
else
|
37
|
+
Logtail::Config.instance.debug { "No session data could be detected, skipping" }
|
38
|
+
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
rescue Exception => e
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require "logtail/config"
|
2
|
+
require "logtail/contexts/user"
|
3
|
+
require "logtail-rack/middleware"
|
4
|
+
|
5
|
+
module Logtail
|
6
|
+
module Integrations
|
7
|
+
module Rack
|
8
|
+
# This is a Rack middleware responsible for setting the user context.
|
9
|
+
# See {Logtail::Contexts::User} for more information on the user context.
|
10
|
+
#
|
11
|
+
# ## Why a Rack middleware?
|
12
|
+
#
|
13
|
+
# We use a Rack middleware because we want to set the user context as early as
|
14
|
+
# possible, and before the initial incoming request log line:
|
15
|
+
#
|
16
|
+
# Started GET /welcome
|
17
|
+
#
|
18
|
+
# The above log line is logged in a request middleware, before it reaches
|
19
|
+
# the controller.
|
20
|
+
#
|
21
|
+
# If, for example, we set the user context in a controller, the log line above
|
22
|
+
# will not have the user context attached. This is because it is logged before
|
23
|
+
# the controller is executed. This is not ideal, and it's why we take a middleware
|
24
|
+
# approach here. If for some reason you cannot identify the user at the middleware
|
25
|
+
# level then setting it in the controller is perfectly fine, just be aware of the
|
26
|
+
# above downside.
|
27
|
+
#
|
28
|
+
# ## Authentication frameworks automatically detected:
|
29
|
+
#
|
30
|
+
# If you use any of the following authentication frameworks, Logtail will
|
31
|
+
# automatically set the user context for you.
|
32
|
+
#
|
33
|
+
# * Devise, or any Warden based authentication strategy
|
34
|
+
# * Clearance
|
35
|
+
#
|
36
|
+
# Or, you can use your own custom authentication, see the {.custom_user_context}
|
37
|
+
# class method for more details.
|
38
|
+
#
|
39
|
+
# @note This middleware is automatically inserted for frameworks we support.
|
40
|
+
# Such as Rails. See {Logtail::Frameworks} for a comprehensive list.
|
41
|
+
class UserContext < Middleware
|
42
|
+
class << self
|
43
|
+
# The custom user context allows you to hook in and set your own custom
|
44
|
+
# user context. This is used in situations where either:
|
45
|
+
#
|
46
|
+
# 1. Logtail does not automatically support your authentication strategy (see module level docs)
|
47
|
+
# 2. You need to customize your authentication beyond Logtail's defaults.
|
48
|
+
#
|
49
|
+
# @example Setting your own custom user context
|
50
|
+
# Logtail::Integrations::Rack::UserContext.custom_user_hash = lambda do |rack_env|
|
51
|
+
# rack_env['my_custom_key'].user
|
52
|
+
# end
|
53
|
+
def custom_user_hash=(proc)
|
54
|
+
if proc && !proc.is_a?(Proc)
|
55
|
+
raise ArgumentError.new("The value passed to #custom_user_hash must be a Proc")
|
56
|
+
end
|
57
|
+
|
58
|
+
@custom_user_hash = proc
|
59
|
+
end
|
60
|
+
|
61
|
+
# Accessor method for {#custom_user_hash=}.
|
62
|
+
def custom_user_hash
|
63
|
+
@custom_user_hash
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def call(env)
|
68
|
+
user_hash = get_user_hash(env)
|
69
|
+
if user_hash
|
70
|
+
CurrentContext.add({user: user_hash})
|
71
|
+
end
|
72
|
+
|
73
|
+
@app.call(env)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
def get_user_hash(env)
|
78
|
+
# The order is relevant here. The 'warden' key can be set, but
|
79
|
+
# not return a user, in which case the user data might be in another key.
|
80
|
+
if self.class.custom_user_hash.is_a?(Proc)
|
81
|
+
Logtail::Config.instance.debug { "Obtaining user context from the custom user hash" }
|
82
|
+
self.class.custom_user_hash.call(env)
|
83
|
+
elsif env[:clearance] && env[:clearance].signed_in?
|
84
|
+
Logtail::Config.instance.debug { "Obtaining user context from the clearance user" }
|
85
|
+
user = env[:clearance].current_user
|
86
|
+
get_user_object_hash(user)
|
87
|
+
elsif env['warden'] && (user = env['warden'].user)
|
88
|
+
Logtail::Config.instance.debug { "Obtaining user context from the warden user" }
|
89
|
+
get_user_object_hash(user)
|
90
|
+
else
|
91
|
+
Logtail::Config.instance.debug { "Could not locate any user data" }
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def get_user_object_hash(user)
|
97
|
+
id = try_user_id(user)
|
98
|
+
name = try_user_name(user)
|
99
|
+
email = try_user_email(user)
|
100
|
+
|
101
|
+
if id || name || email
|
102
|
+
{id: id, name: name, email: email}
|
103
|
+
else
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def try_user_id(user)
|
109
|
+
user.respond_to?(:id) ? user.id : nil
|
110
|
+
end
|
111
|
+
|
112
|
+
def try_user_name(user)
|
113
|
+
if user.respond_to?(:name) && user.name.is_a?(String)
|
114
|
+
user.name
|
115
|
+
elsif user.respond_to?(:first_name) && user.first_name.is_a?(String) && user.respond_to?(:last_name) && user.last_name.is_a?(String)
|
116
|
+
"#{user.first_name} #{user.last_name}"
|
117
|
+
else
|
118
|
+
nil
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def try_user_email(user)
|
123
|
+
if user.respond_to?(:email) && user.email.is_a?(String)
|
124
|
+
user.email
|
125
|
+
else
|
126
|
+
nil
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Logtail
|
2
|
+
module Util
|
3
|
+
# @private
|
4
|
+
class Request < ::Rack::Request
|
5
|
+
# We store strings as constants since they are reused on a per request basis.
|
6
|
+
# This avoids string allocations.
|
7
|
+
HTTP_HEADER_ORIGINAL_DELIMITER = '_'.freeze
|
8
|
+
HTTP_HEADER_NEW_DELIMITER = '_'.freeze
|
9
|
+
HTTP_PREFIX = 'HTTP_'.freeze
|
10
|
+
|
11
|
+
REMOTE_IP_KEY_NAME = 'action_dispatch.remote_ip'.freeze
|
12
|
+
REQUEST_ID_KEY_NAME1 = 'action_dispatch.request_id'.freeze
|
13
|
+
REQUEST_ID_KEY_NAME2 = 'HTTP_X_REQUEST_ID'.freeze
|
14
|
+
|
15
|
+
def body_content
|
16
|
+
content = body.read
|
17
|
+
body.rewind
|
18
|
+
content
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns a list of request headers. The rack env contains a lot of data, this function
|
22
|
+
# identifies those that were the actual request headers.
|
23
|
+
#
|
24
|
+
# This was extracted from: https://github.com/ruby-grape/grape/blob/91c6c78ae3d3f3ffabaf57ffc4dc35ab7cfc7b5f/lib/grape/request.rb#L30
|
25
|
+
def headers
|
26
|
+
@headers ||= begin
|
27
|
+
headers = {}
|
28
|
+
|
29
|
+
@env.each_pair do |k, v|
|
30
|
+
next unless k.is_a?(String) && k.to_s.start_with?(HTTP_PREFIX)
|
31
|
+
|
32
|
+
k = k[5..-1].
|
33
|
+
split(HTTP_HEADER_ORIGINAL_DELIMITER).
|
34
|
+
each(&:capitalize!).
|
35
|
+
join(HTTP_HEADER_NEW_DELIMITER)
|
36
|
+
|
37
|
+
headers[k] = v
|
38
|
+
end
|
39
|
+
|
40
|
+
headers
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def ip
|
45
|
+
@ip ||= if @env[REMOTE_IP_KEY_NAME]
|
46
|
+
@env[REMOTE_IP_KEY_NAME].to_s || super
|
47
|
+
else
|
48
|
+
super
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def referer
|
53
|
+
# Rails 3.X returns "/" for some reason
|
54
|
+
@referer ||= super == "/" ? nil : super
|
55
|
+
end
|
56
|
+
|
57
|
+
def request_id
|
58
|
+
@request_id ||= @env[REQUEST_ID_KEY_NAME1] ||
|
59
|
+
@env[REQUEST_ID_KEY_NAME2]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
lib = File.expand_path("../lib", __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require "logtail-rack/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "logtail-rack"
|
7
|
+
spec.version = Logtail::Integrations::Rack::VERSION
|
8
|
+
spec.authors = ["Logtail"]
|
9
|
+
spec.email = ["hi@logtail.com"]
|
10
|
+
|
11
|
+
spec.summary = %q{Logtail integration for Rack}
|
12
|
+
spec.homepage = "https://github.com/logtail/logtail-ruby-rack"
|
13
|
+
spec.license = "ISC"
|
14
|
+
|
15
|
+
spec.required_ruby_version = '>= 2.2.10'
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/logtail/logtail-ruby-rack"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/logtail/logtail-ruby-rack/blob/master/README.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "exe"
|
27
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
spec.add_dependency "logtail-ruby", "~> 0.1"
|
31
|
+
spec.add_runtime_dependency "rack", ">= 1.2", "< 3.0"
|
32
|
+
|
33
|
+
spec.add_development_dependency "bundler", ">= 0.0"
|
34
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
35
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
36
|
+
end
|
metadata
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: logtail-rack
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Logtail
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-02-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: logtail-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rack
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.2'
|
34
|
+
- - "<"
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: '3.0'
|
37
|
+
type: :runtime
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '1.2'
|
44
|
+
- - "<"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '3.0'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: bundler
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0.0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0.0'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: rake
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '10.0'
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '10.0'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: rspec
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '3.0'
|
82
|
+
type: :development
|
83
|
+
prerelease: false
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
85
|
+
requirements:
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: '3.0'
|
89
|
+
description:
|
90
|
+
email:
|
91
|
+
- hi@logtail.com
|
92
|
+
executables: []
|
93
|
+
extensions: []
|
94
|
+
extra_rdoc_files: []
|
95
|
+
files:
|
96
|
+
- ".github/workflows/main.yml"
|
97
|
+
- ".gitignore"
|
98
|
+
- ".rspec"
|
99
|
+
- Gemfile
|
100
|
+
- LICENSE.md
|
101
|
+
- README.md
|
102
|
+
- Rakefile
|
103
|
+
- lib/logtail-rack.rb
|
104
|
+
- lib/logtail-rack/config.rb
|
105
|
+
- lib/logtail-rack/error_event.rb
|
106
|
+
- lib/logtail-rack/http_context.rb
|
107
|
+
- lib/logtail-rack/http_events.rb
|
108
|
+
- lib/logtail-rack/http_request.rb
|
109
|
+
- lib/logtail-rack/http_response.rb
|
110
|
+
- lib/logtail-rack/middleware.rb
|
111
|
+
- lib/logtail-rack/session_context.rb
|
112
|
+
- lib/logtail-rack/user_context.rb
|
113
|
+
- lib/logtail-rack/util/request.rb
|
114
|
+
- lib/logtail-rack/version.rb
|
115
|
+
- logtail-ruby-rack.gemspec
|
116
|
+
homepage: https://github.com/logtail/logtail-ruby-rack
|
117
|
+
licenses:
|
118
|
+
- ISC
|
119
|
+
metadata:
|
120
|
+
homepage_uri: https://github.com/logtail/logtail-ruby-rack
|
121
|
+
source_code_uri: https://github.com/logtail/logtail-ruby-rack
|
122
|
+
changelog_uri: https://github.com/logtail/logtail-ruby-rack/blob/master/README.md
|
123
|
+
post_install_message:
|
124
|
+
rdoc_options: []
|
125
|
+
require_paths:
|
126
|
+
- lib
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: 2.2.10
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
requirements: []
|
138
|
+
rubygems_version: 3.2.3
|
139
|
+
signing_key:
|
140
|
+
specification_version: 4
|
141
|
+
summary: Logtail integration for Rack
|
142
|
+
test_files: []
|