packrat-rails 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 +70 -0
- data/lib/generators/packrat/packrat_generator.rb +62 -0
- data/lib/generators/packrat/templates/initializer.rb.tt +12 -0
- data/lib/packrat/config.rb +139 -0
- data/lib/packrat/otel/error_spans.rb +71 -0
- data/lib/packrat/otel/root_kind_sampler.rb +25 -0
- data/lib/packrat/otel/scrubber.rb +111 -0
- data/lib/packrat/otel.rb +250 -0
- data/lib/packrat/rack_span.rb +23 -0
- data/lib/packrat/railtie.rb +23 -0
- data/lib/packrat/version.rb +3 -0
- data/lib/packrat-rails.rb +1 -0
- data/lib/packrat.rb +48 -0
- metadata +323 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 66ef80151ead412a9237f8e93fc4d916ba4041b43518e4d7b25c9470ace3d90b
|
|
4
|
+
data.tar.gz: 26eb535015852176b0aa847ae220a0204b83f0bf6d201723f7cf2284b5ecd4ff
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b9e188a68993bca9520e3300c20cc30ac19a122a19f369373aa20c278d07db7a740faceef69397cf34a17f9782628b2d29124b794f4d8c6ed6f8e0f06fa89911
|
|
7
|
+
data.tar.gz: ccf0946cbdd06bc2b30ee3ce01b234040853948cbdcab1d0578337165cb27114b30861a9b7035dc09a7022b920482fea7b7f8e4a92904730dff3c01888a9ccf5
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Packrat Labs
|
|
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,70 @@
|
|
|
1
|
+
# packrat-rails
|
|
2
|
+
|
|
3
|
+
**To turn it off, set `PACKRAT_DISABLED=1`.** The app then boots as if the gem were not installed.
|
|
4
|
+
|
|
5
|
+
OpenTelemetry for Rails, configured to send traces, logs and exceptions to
|
|
6
|
+
[Packrat](https://packrat.sh) with one key.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bundle add packrat-rails
|
|
10
|
+
bin/rails generate packrat --ingest-key=packrat_ingest_…
|
|
11
|
+
bin/rails server
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The generator creates `config/initializers/packrat.rb` with the ingest key from your Packrat setup
|
|
15
|
+
page:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Packrat.configure do |config|
|
|
19
|
+
config.ingest_key = ENV.fetch("PACKRAT_INGEST_KEY", "packrat_ingest_…")
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
An ingest key can only send data, so you can commit the file. To keep the key out of the repository,
|
|
24
|
+
run the generator without `--ingest-key` and set `PACKRAT_INGEST_KEY` where the app runs.
|
|
25
|
+
|
|
26
|
+
The generator also turns on the [Packrat plugin for Claude Code](https://packrat.sh/docs/claude-code-plugin)
|
|
27
|
+
in `.claude/settings.json`. Commit the file, and everyone who opens Claude Code in the repository and
|
|
28
|
+
trusts the folder gets the plugin. If the file exists, the generator adds the plugin and keeps the rest.
|
|
29
|
+
To leave the file alone, pass `--no-claude-code`.
|
|
30
|
+
|
|
31
|
+
On boot the gem writes one line to the Rails log. It names the service and environment the app
|
|
32
|
+
reports under:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Packrat: sending traces and logs for shop/production to https://ingest.packrat.sh (rack, action_pack, active_job, active_record, net_http, pg, logger)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
The ingest key and the ingest URL are the only settings in `Packrat.configure`. Everything else is an environment
|
|
41
|
+
variable.
|
|
42
|
+
|
|
43
|
+
| Variable | Default | Note |
|
|
44
|
+
| ------------------------------- | --------------------------- | ------------------------------------------------------------ |
|
|
45
|
+
| `PACKRAT_INGEST_KEY` | `config.ingest_key` | Wins over the initializer. With neither, the gem sends nothing and says so |
|
|
46
|
+
| `PACKRAT_INGEST_URL` | `config.ingest_url`, else `https://ingest.packrat.sh` | A local Packrat is `http://localhost:3100`. `--ingest-url` on the generator writes `config.ingest_url` |
|
|
47
|
+
| `PACKRAT_DISABLED` | unset | `1` turns the gem off in any environment |
|
|
48
|
+
| `PACKRAT_OTEL_INSTRUMENTATIONS` | the list below | Comma-separated names to add; a leading `-` removes one |
|
|
49
|
+
|
|
50
|
+
The instrumentations are `rack`, `action_pack`, `action_view`, `active_job`, `active_record`,
|
|
51
|
+
`net_http`, `pg`, `mysql2`, `sidekiq`, `redis`, `aws_sdk`, `concurrent_ruby`, `rake` and `logger`. One for a library that is not in the app installs nothing.
|
|
52
|
+
`PACKRAT_OTEL_INSTRUMENTATIONS=faraday,-action_view` adds Faraday (with
|
|
53
|
+
`opentelemetry-instrumentation-faraday` in the Gemfile) and removes Action View. `action_mailer` and
|
|
54
|
+
`active_storage` cannot be added: they must load before boot ends.
|
|
55
|
+
|
|
56
|
+
The service name is `OTEL_SERVICE_NAME`, else `service.name` in `OTEL_RESOURCE_ATTRIBUTES`, else the
|
|
57
|
+
app's module name (`Shop::Application` is `shop`). The environment is `Rails.env` unless
|
|
58
|
+
`OTEL_RESOURCE_ATTRIBUTES` names one. The version is `BUILD_COMMIT`, `HEROKU_SLUG_COMMIT` or
|
|
59
|
+
`SOURCE_VERSION`. The instance is `DYNO`, else the host name. A standard `OTEL_*` variable that is set always wins.
|
|
60
|
+
|
|
61
|
+
The gem does nothing in the test environment.
|
|
62
|
+
|
|
63
|
+
## Beside other tools
|
|
64
|
+
|
|
65
|
+
- **Nothing else:** the gem configures OpenTelemetry.
|
|
66
|
+
- **Your own OpenTelemetry setup:** the gem leaves it as it is and adds a second exporter, to Packrat.
|
|
67
|
+
If your exporter already sends to Packrat, it adds nothing.
|
|
68
|
+
- **Sentry:** both run. The gem does not touch Sentry.
|
|
69
|
+
- **Datadog's tracer:** the gem does nothing. Dual-ship from the Datadog Agent instead:
|
|
70
|
+
https://packrat.sh/docs/datadog
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "rails/generators"
|
|
3
|
+
|
|
4
|
+
module Packrat
|
|
5
|
+
module Generators
|
|
6
|
+
# bin/rails generate packrat --ingest-key=packrat_ingest_...
|
|
7
|
+
class PackratGenerator < Rails::Generators::Base
|
|
8
|
+
namespace "packrat"
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates config/initializers/packrat.rb, which gives the packrat-rails gem your ingest key, " \
|
|
12
|
+
"and turns on the Packrat plugin for Claude Code in .claude/settings.json."
|
|
13
|
+
class_option :ingest_key, type: :string, desc: "The ingest key from your Packrat setup page"
|
|
14
|
+
class_option :ingest_url, type: :string, desc: "Where to send, when it isn't https://ingest.packrat.sh"
|
|
15
|
+
class_option :claude_code, type: :boolean, default: true,
|
|
16
|
+
desc: "Turn on the Packrat plugin for Claude Code in .claude/settings.json"
|
|
17
|
+
|
|
18
|
+
CLAUDE_SETTINGS = ".claude/settings.json"
|
|
19
|
+
MARKETPLACE = { "source" => { "source" => "github", "repo" => "packratlabs/claude-plugin" } }.freeze
|
|
20
|
+
PLUGIN = "packrat@packrat"
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template "initializer.rb.tt", "config/initializers/packrat.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Everyone who opens Claude Code in the repository and trusts the folder gets the plugin. Other
|
|
27
|
+
# settings in the file are kept, and so is a plugin someone turned off on purpose.
|
|
28
|
+
def enable_claude_code_plugin
|
|
29
|
+
return unless options[:claude_code]
|
|
30
|
+
|
|
31
|
+
path = File.join(destination_root, CLAUDE_SETTINGS)
|
|
32
|
+
return create_file(CLAUDE_SETTINGS, settings_json({})) unless File.exist?(path)
|
|
33
|
+
|
|
34
|
+
current = JSON.parse(File.read(path))
|
|
35
|
+
merged = settings_json(current)
|
|
36
|
+
return say_status(:identical, CLAUDE_SETTINGS, :blue) if merged == File.read(path)
|
|
37
|
+
|
|
38
|
+
File.write(path, merged)
|
|
39
|
+
say_status :update, CLAUDE_SETTINGS
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
say_status :skip, "#{CLAUDE_SETTINGS} isn't valid JSON; add the Packrat plugin to it yourself", :yellow
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def ingest_key
|
|
47
|
+
options[:ingest_key].to_s.strip
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ingest_url
|
|
51
|
+
options[:ingest_url].to_s.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def settings_json(settings)
|
|
55
|
+
settings = settings.dup
|
|
56
|
+
settings["extraKnownMarketplaces"] = { "packrat" => MARKETPLACE }.merge(settings["extraKnownMarketplaces"] || {})
|
|
57
|
+
settings["enabledPlugins"] = { PLUGIN => true }.merge(settings["enabledPlugins"] || {})
|
|
58
|
+
"#{JSON.pretty_generate(settings)}\n"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Sends your app's exceptions, traces and logs to Packrat: https://packrat.sh/docs/ruby-on-rails
|
|
2
|
+
# PACKRAT_INGEST_KEY, when it is set, wins over the key written here.
|
|
3
|
+
Packrat.configure do |config|
|
|
4
|
+
<% if ingest_key.empty? -%>
|
|
5
|
+
config.ingest_key = ENV["PACKRAT_INGEST_KEY"]
|
|
6
|
+
<% else -%>
|
|
7
|
+
config.ingest_key = ENV.fetch("PACKRAT_INGEST_KEY", <%= ingest_key.inspect %>)
|
|
8
|
+
<% end -%>
|
|
9
|
+
<% unless ingest_url.empty? -%>
|
|
10
|
+
config.ingest_url = <%= ingest_url.inspect %>
|
|
11
|
+
<% end -%>
|
|
12
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Packrat
|
|
5
|
+
# Everything the gem reads, all of it from the environment. An OTEL_* variable that is set always
|
|
6
|
+
# wins: the gem fills in only what the standard variables leave empty.
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_URL = "https://ingest.packrat.sh".freeze
|
|
9
|
+
INSTRUMENTATIONS = %w[rack action_pack action_view active_job active_record net_http pg mysql2 sidekiq redis aws_sdk concurrent_ruby rake logger].freeze
|
|
10
|
+
COMMIT_VARIABLES = %w[BUILD_COMMIT HEROKU_SLUG_COMMIT SOURCE_VERSION].freeze
|
|
11
|
+
REDACTED = "[redacted]".freeze
|
|
12
|
+
DISTRO_ATTRIBUTES = { "telemetry.distro.name" => "packrat-rails", "telemetry.distro.version" => VERSION }.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :rails_env, :app_name
|
|
15
|
+
attr_writer :ingest_key, :ingest_url
|
|
16
|
+
|
|
17
|
+
def initialize(env: ENV, rails_env: nil, app_name: nil)
|
|
18
|
+
@env = env
|
|
19
|
+
@rails_env = rails_env.to_s
|
|
20
|
+
@app_name = app_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The variable wins over the initializer, so a deploy can change the key without a commit.
|
|
24
|
+
def key
|
|
25
|
+
present(@env["PACKRAT_INGEST_KEY"]) || present(@ingest_key)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def url
|
|
29
|
+
(present(@env["PACKRAT_INGEST_URL"]) || present(@ingest_url) || DEFAULT_URL).chomp("/")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def traces_endpoint
|
|
33
|
+
"#{url}/v1/traces"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def logs_endpoint
|
|
37
|
+
"#{url}/v1/logs"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def headers
|
|
41
|
+
{ "Authorization" => key }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Why the gem does nothing, or nil when it runs. Test is silent: a line per test process is noise.
|
|
45
|
+
def off_reason
|
|
46
|
+
if rails_env == "test" then nil
|
|
47
|
+
elsif flag?("PACKRAT_DISABLED") then "PACKRAT_DISABLED is set, so nothing is sent"
|
|
48
|
+
elsif sdk_disabled? then "OTEL_SDK_DISABLED is true, so nothing is sent"
|
|
49
|
+
elsif key.nil? then "no ingest key is set (PACKRAT_INGEST_KEY or config.ingest_key), so nothing is sent"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
allowed? && !key.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Everything but the key. The middleware is placed before config/initializers set the key.
|
|
58
|
+
def allowed?
|
|
59
|
+
rails_env != "test" && !flag?("PACKRAT_DISABLED") && !sdk_disabled?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# PACKRAT_OTEL_INSTRUMENTATIONS=sidekiq,-pg adds sidekiq to the allowlist and takes pg out.
|
|
63
|
+
def instrumentations
|
|
64
|
+
@env["PACKRAT_OTEL_INSTRUMENTATIONS"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
65
|
+
.reduce(INSTRUMENTATIONS.dup) do |names, entry|
|
|
66
|
+
entry.start_with?("-") ? names - [ entry.delete_prefix("-") ] : names | [ entry ]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def service_name
|
|
71
|
+
present(@env["OTEL_SERVICE_NAME"]) || env_attributes["service.name"] || app_name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def environment
|
|
75
|
+
env_attributes["deployment.environment.name"] || env_attributes["deployment.environment"] || rails_env
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def service_version
|
|
79
|
+
env_attributes["service.version"] || COMMIT_VARIABLES.lazy.map { |name| present(@env[name]) }.find(&:itself)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Only what the standard variables leave unset; the SDK's default resource already holds theirs.
|
|
83
|
+
def resource_attributes
|
|
84
|
+
{}.tap do |attributes|
|
|
85
|
+
attributes["service.name"] = app_name if app_name && service_name == app_name
|
|
86
|
+
unless env_attributes.key?("deployment.environment.name") || env_attributes.key?("deployment.environment")
|
|
87
|
+
attributes["deployment.environment.name"] = rails_env
|
|
88
|
+
end
|
|
89
|
+
version = service_version
|
|
90
|
+
attributes["service.version"] = version if version && !env_attributes.key?("service.version")
|
|
91
|
+
# DYNO names the process on Heroku and build.io: web.1, worker.2.
|
|
92
|
+
attributes["service.instance.id"] = present(@env["DYNO"]) || Socket.gethostname unless env_attributes.key?("service.instance.id")
|
|
93
|
+
# Packrat reads telemetry.distro.name to tell this gem's data from a hand-built setup.
|
|
94
|
+
DISTRO_ATTRIBUTES.each { |name, value| attributes[name] = value unless env_attributes.key?(name) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def set?(name)
|
|
99
|
+
!present(@env[name]).nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The app's own exporter names the ingest host, so a second exporter would send every span twice.
|
|
103
|
+
def app_exports_to_packrat?
|
|
104
|
+
ours = URI(url).then { |uri| [ uri.host, uri.port ] }
|
|
105
|
+
%w[OTEL_EXPORTER_OTLP_ENDPOINT OTEL_EXPORTER_OTLP_TRACES_ENDPOINT].any? do |name|
|
|
106
|
+
(URI(present(@env[name]).to_s).then { |uri| [ uri.host, uri.port ] } rescue nil) == ours
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inspect
|
|
111
|
+
"#<#{self.class.name} url=#{url} key=#{key ? REDACTED : 'unset'} service=#{service_name} environment=#{environment}>"
|
|
112
|
+
end
|
|
113
|
+
alias to_s inspect
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# The SDK reads OTEL_RESOURCE_ATTRIBUTES the same way: split on commas, then on "=".
|
|
118
|
+
def env_attributes
|
|
119
|
+
@env_attributes ||= @env["OTEL_RESOURCE_ATTRIBUTES"].to_s.split(",").filter_map do |pair|
|
|
120
|
+
name, value = pair.split("=", 2).map { |part| part.to_s.strip }
|
|
121
|
+
[ name, value ] unless name.empty? || value.empty?
|
|
122
|
+
end.to_h
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def sdk_disabled?
|
|
126
|
+
@env["OTEL_SDK_DISABLED"].to_s.strip.casecmp?("true")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def flag?(name)
|
|
130
|
+
value = present(@env[name])
|
|
131
|
+
!value.nil? && !%w[0 false no].include?(value.downcase)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def present(value)
|
|
135
|
+
value = value.to_s.strip
|
|
136
|
+
value.empty? ? nil : value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Packrat
|
|
4
|
+
module Otel
|
|
5
|
+
# Everything Rails.error hears -- an error that escapes a request or a job, one the app rescues and
|
|
6
|
+
# reports itself, one in a runner script -- recorded on the span it happened in, or on a span of its
|
|
7
|
+
# own when there is none.
|
|
8
|
+
class ErrorSpans
|
|
9
|
+
NAME = "Rails.error"
|
|
10
|
+
# Rails puts the live controller and job objects in the context.
|
|
11
|
+
SKIPPED_KEYS = %i[controller job].freeze
|
|
12
|
+
# Rails reports these after the request or job instrumentation has recorded the same error.
|
|
13
|
+
INSTRUMENTED_SOURCES = %w[application.action_dispatch job.sidekiq].freeze
|
|
14
|
+
# A cache failure falls back to the source of truth; Sentry skips these too.
|
|
15
|
+
CACHE_STORE_SOURCE = /_cache_store\.active_support\z/
|
|
16
|
+
|
|
17
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
18
|
+
return if skipped?(handled, source)
|
|
19
|
+
|
|
20
|
+
exception = error.is_a?(Exception) ? error : RuntimeError.new(error.to_s)
|
|
21
|
+
attributes = exception_attributes(exception, handled, severity, source).merge(context_attributes(context))
|
|
22
|
+
span = OpenTelemetry::Trace.current_span
|
|
23
|
+
return span.record_exception(exception, attributes:) if span.recording?
|
|
24
|
+
|
|
25
|
+
span = OpenTelemetry.tracer_provider.tracer("rails.error").start_root_span(NAME)
|
|
26
|
+
span.record_exception(exception, attributes:)
|
|
27
|
+
span.finish
|
|
28
|
+
rescue StandardError => error
|
|
29
|
+
OpenTelemetry.handle_error(exception: error, message: "Packrat::Otel::ErrorSpans#report failed")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def skipped?(handled, source)
|
|
35
|
+
(!handled && INSTRUMENTED_SOURCES.include?(source)) || CACHE_STORE_SOURCE.match?(source.to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Span events are not scrubbed on export, so the message and the stack are scrubbed here.
|
|
39
|
+
def exception_attributes(exception, handled, severity, source)
|
|
40
|
+
{
|
|
41
|
+
"exception.message" => scrub(exception.message),
|
|
42
|
+
"exception.stacktrace" => scrub(exception.full_message(highlight: false, order: :top)),
|
|
43
|
+
"exception.handled" => handled,
|
|
44
|
+
"rails.error.severity" => severity.to_s,
|
|
45
|
+
"rails.error.source" => source
|
|
46
|
+
}.compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def context_attributes(context)
|
|
50
|
+
values = context.except(*SKIPPED_KEYS, :tags)
|
|
51
|
+
values = values.merge(context[:tags]) if context[:tags].is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
values.each_with_object({}) do |(key, value), attributes|
|
|
54
|
+
attributes["rails.error.context.#{key}"] = attribute_value(value) unless value.nil?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def attribute_value(value)
|
|
59
|
+
case value
|
|
60
|
+
when Integer, Float, true, false then value
|
|
61
|
+
when Hash, Array then scrub(value.to_json)
|
|
62
|
+
else scrub(value.to_s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scrub(text)
|
|
67
|
+
Scrubber.scrub_text(text.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�"))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Packrat
|
|
2
|
+
module Otel
|
|
3
|
+
# A trace that starts with a request (server) or a job (consumer) is work. One that starts with
|
|
4
|
+
# anything else is a poll: Solid Queue and Solid Cable each ask Postgres for work about ten times
|
|
5
|
+
# a second, and every poll would otherwise be a trace of its own. Children never reach this class;
|
|
6
|
+
# parent_based routes only roots here, and a child follows its root. A Rake task is work too,
|
|
7
|
+
# though its span is internal; when Rails boots inside the task, the root is its execute.
|
|
8
|
+
class RootKindSampler
|
|
9
|
+
KEEP = %i[server consumer].freeze
|
|
10
|
+
NAMES = [ ErrorSpans::NAME, "rake.invoke", "rake.execute" ].freeze
|
|
11
|
+
|
|
12
|
+
def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:)
|
|
13
|
+
keep = KEEP.include?(kind) || NAMES.include?(name)
|
|
14
|
+
decision = keep ? OpenTelemetry::SDK::Trace::Samplers::Decision::RECORD_AND_SAMPLE
|
|
15
|
+
: OpenTelemetry::SDK::Trace::Samplers::Decision::DROP
|
|
16
|
+
tracestate = OpenTelemetry::Trace.current_span(parent_context).context.tracestate
|
|
17
|
+
OpenTelemetry::SDK::Trace::Samplers::Result.new(decision:, tracestate:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def description
|
|
21
|
+
"RootKindSampler{#{KEEP.join(',')}}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module Packrat
|
|
4
|
+
module Otel
|
|
5
|
+
# Removes credentials before telemetry leaves the process. Packrat archives raw data and never
|
|
6
|
+
# rewrites it, so anything that survives here is kept for good.
|
|
7
|
+
#
|
|
8
|
+
# Query strings are kept on purpose: they carry what a request was about, which is most of what
|
|
9
|
+
# a trace is read for. Only the values of credential-named parameters are replaced.
|
|
10
|
+
module Scrubber
|
|
11
|
+
REDACTED = "[REDACTED]".freeze
|
|
12
|
+
URL_KEYS = %w[http.target http.url url.full url.query].freeze
|
|
13
|
+
HEADER_PREFIXES = %w[http.request.header. http.response.header.].freeze
|
|
14
|
+
# A whole parameter name, or one segment of a snake_case name: api_key and access_token match,
|
|
15
|
+
# keyword and monkey do not.
|
|
16
|
+
CREDENTIAL_PARAM = /\A(?:[a-z0-9]+_)*(?:key|token|password|passwd|secret|signature|sig|auth|credential)(?:_[a-z0-9]+)*\z/i
|
|
17
|
+
# Free text is redacted by pattern: log lines and error messages interpolate URLs, which is
|
|
18
|
+
# where a credential usually appears.
|
|
19
|
+
CREDENTIAL_IN_TEXT = /
|
|
20
|
+
(?<name>\b(?:[a-z0-9]+_)?(?:api_key|access_token|key|token|password|passwd|secret|signature|sig|auth|credential)\s*=\s*)
|
|
21
|
+
(?<value>[^\s&"'<>\]]+)
|
|
22
|
+
/ix
|
|
23
|
+
BEARER = %r{\b(?<scheme>Bearer|Basic)\s+(?<value>[A-Za-z0-9._~+/-]+=*)}i
|
|
24
|
+
ANSI = /\e\[[0-9;]*m/
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def scrub_text(text)
|
|
28
|
+
return text unless text.is_a?(String)
|
|
29
|
+
|
|
30
|
+
text.gsub(CREDENTIAL_IN_TEXT) { "#{Regexp.last_match(:name)}#{REDACTED}" }
|
|
31
|
+
.gsub(BEARER) { "#{Regexp.last_match(:scheme)} #{REDACTED}" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The same hash when nothing needs to change, so callers can tell by identity.
|
|
35
|
+
def scrub_attributes(attributes)
|
|
36
|
+
return attributes if attributes.nil?
|
|
37
|
+
return attributes unless attributes.any? { |key, value| header?(key) || redactable_url?(key, value) }
|
|
38
|
+
|
|
39
|
+
attributes.each_with_object({}) do |(key, value), scrubbed|
|
|
40
|
+
next if header?(key)
|
|
41
|
+
|
|
42
|
+
scrubbed[key] = URL_KEYS.include?(key) ? redact_query(value) : value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# SpanData and LogRecordData are frozen structs, so a changed one is rebuilt from its fields.
|
|
47
|
+
def scrub_spans(spans)
|
|
48
|
+
spans.map do |span|
|
|
49
|
+
attributes = scrub_attributes(span.attributes)
|
|
50
|
+
attributes.equal?(span.attributes) ? span : span.class.new(*span.to_h.merge(attributes:).values)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def scrub_log_records(records)
|
|
55
|
+
records.map do |record|
|
|
56
|
+
attributes = scrub_attributes(record.attributes)
|
|
57
|
+
body = record.body.is_a?(String) ? scrub_text(record.body.gsub(ANSI, "")) : record.body
|
|
58
|
+
next record if attributes.equal?(record.attributes) && body == record.body
|
|
59
|
+
|
|
60
|
+
record.class.new(*record.to_h.merge(attributes:, body:).values)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def header?(key)
|
|
67
|
+
HEADER_PREFIXES.any? { |prefix| key.to_s.start_with?(prefix) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def redactable_url?(key, value)
|
|
71
|
+
URL_KEYS.include?(key) && value.to_s.include?("=")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def redact_query(url)
|
|
75
|
+
head, separator, query = url.to_s.include?("?") ? url.to_s.partition("?") : [ "", "", url.to_s ]
|
|
76
|
+
return url if query.empty?
|
|
77
|
+
|
|
78
|
+
redacted = query.split("&").map do |pair|
|
|
79
|
+
name, equals, _value = pair.partition("=")
|
|
80
|
+
equals.empty? || !CREDENTIAL_PARAM.match?(CGI.unescape(name)) ? pair : "#{name}=#{REDACTED}"
|
|
81
|
+
end
|
|
82
|
+
"#{head}#{separator}#{redacted.join('&')}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class ScrubbingSpanExporter
|
|
88
|
+
def initialize(exporter)
|
|
89
|
+
@exporter = exporter
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def export(spans, timeout: nil)
|
|
93
|
+
@exporter.export(Scrubber.scrub_spans(spans), timeout:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def force_flush(timeout: nil)
|
|
97
|
+
@exporter.force_flush(timeout:)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def shutdown(timeout: nil)
|
|
101
|
+
@exporter.shutdown(timeout:)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class ScrubbingLogExporter < ScrubbingSpanExporter
|
|
106
|
+
def export(records, timeout: nil)
|
|
107
|
+
@exporter.export(Scrubber.scrub_log_records(records), timeout:)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/packrat/otel.rb
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Packrat
|
|
4
|
+
module Otel
|
|
5
|
+
CLASSES = {
|
|
6
|
+
"rack" => "OpenTelemetry::Instrumentation::Rack",
|
|
7
|
+
"action_pack" => "OpenTelemetry::Instrumentation::ActionPack",
|
|
8
|
+
"active_job" => "OpenTelemetry::Instrumentation::ActiveJob",
|
|
9
|
+
"active_record" => "OpenTelemetry::Instrumentation::ActiveRecord",
|
|
10
|
+
"net_http" => "OpenTelemetry::Instrumentation::Net::HTTP",
|
|
11
|
+
"pg" => "OpenTelemetry::Instrumentation::PG",
|
|
12
|
+
"mysql2" => "OpenTelemetry::Instrumentation::Mysql2",
|
|
13
|
+
"logger" => "OpenTelemetry::Instrumentation::Logger"
|
|
14
|
+
}.freeze
|
|
15
|
+
# The health check runs every few seconds and would otherwise be most of what is stored.
|
|
16
|
+
OPTIONS = { "rack" => { untraced_endpoints: [ "/up" ] } }.freeze
|
|
17
|
+
# These install through the Active Support instrumentation and do not require it themselves.
|
|
18
|
+
PREREQUISITES = { "action_view" => "active_support" }.freeze
|
|
19
|
+
# These set up in an after_initialize block of their own Railtie, which cannot run once boot has
|
|
20
|
+
# reached Packrat's.
|
|
21
|
+
BOOT_ONLY = %w[action_mailer active_storage].freeze
|
|
22
|
+
ACTION_VIEW_EVENTS = %w[render_template render_partial render_collection render_layout].map { |event| "#{event}.action_view" }.freeze
|
|
23
|
+
EXIT_TIMEOUT = 5
|
|
24
|
+
|
|
25
|
+
# dogstatsd-ruby defines Datadog::Statsd without the tracer, hence the method check.
|
|
26
|
+
def self.datadog?
|
|
27
|
+
!!(defined?(::Datadog) && ::Datadog.respond_to?(:configuration))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The one decision, made at the end of boot: configure OpenTelemetry when nothing has, add a
|
|
31
|
+
# Packrat exporter beside the app's own when something has, and stay out of Datadog's way.
|
|
32
|
+
class Setup
|
|
33
|
+
attr_reader :outcome, :line, :installed, :warnings
|
|
34
|
+
|
|
35
|
+
def initialize(config:, logger:, errors:)
|
|
36
|
+
@config = config
|
|
37
|
+
@logger = logger
|
|
38
|
+
@errors = errors
|
|
39
|
+
@installed = []
|
|
40
|
+
@warnings = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
provider = OpenTelemetry.tracer_provider
|
|
45
|
+
if !@config.enabled? then off
|
|
46
|
+
elsif Otel.datadog? then datadog
|
|
47
|
+
elsif provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider) then append(provider)
|
|
48
|
+
else configure
|
|
49
|
+
end
|
|
50
|
+
@logger.info(@line) if @line
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The Rack span, for the middleware the Railtie put in the stack before this decision was made.
|
|
55
|
+
def rack(app)
|
|
56
|
+
return app unless @outcome == :configured && @installed.include?("rack")
|
|
57
|
+
|
|
58
|
+
middleware, *args = instrumentation("rack").middleware_args
|
|
59
|
+
middleware.new(app, *args)
|
|
60
|
+
rescue StandardError, LoadError => error
|
|
61
|
+
failed("rack middleware", error)
|
|
62
|
+
app
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def off
|
|
68
|
+
@outcome = :off
|
|
69
|
+
@line = @config.off_reason && "Packrat: #{@config.off_reason}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def datadog
|
|
73
|
+
@outcome = :datadog
|
|
74
|
+
@line = "Packrat: Datadog found; configure the Agent to dual-ship, see https://packrat.sh/docs/datadog"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def configure
|
|
78
|
+
logs = step("logs SDK") { load_logs }
|
|
79
|
+
spans = step("traces exporter") { span_processor }
|
|
80
|
+
records = logs && step("logs exporter") { log_record_processor }
|
|
81
|
+
return @line = "Packrat: OpenTelemetry was not configured, so nothing is sent" unless spans && step("SDK") { configure_sdk(spans, records) }
|
|
82
|
+
|
|
83
|
+
@outcome = :configured
|
|
84
|
+
step("sampler") { use_sampler } unless @config.set?("OTEL_TRACES_SAMPLER")
|
|
85
|
+
@config.instrumentations.each { |name| step("instrumentation #{name}") { install(name) } }
|
|
86
|
+
step("Rails.error") { @errors.subscribe(ErrorSpans.new) }
|
|
87
|
+
step("at_exit") { at_exit { shutdown_providers } }
|
|
88
|
+
@line = "Packrat: sending #{signals(records)} for #{@config.service_name}/#{@config.environment} " \
|
|
89
|
+
"to #{@config.url} (#{@installed.join(', ')})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def append(provider)
|
|
93
|
+
if @config.app_exports_to_packrat?
|
|
94
|
+
@outcome = :already
|
|
95
|
+
return @line = "Packrat: the app's OpenTelemetry exporter already sends to #{@config.url}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
spans = step("traces exporter") { span_processor }
|
|
99
|
+
return @line = "Packrat: the app's OpenTelemetry was left alone, and nothing is sent" unless spans && step("traces processor") { provider.add_span_processor(spans) }
|
|
100
|
+
|
|
101
|
+
@outcome = :appended
|
|
102
|
+
records = sdk_logger_provider && logger_instrumented? && step("logs exporter") { log_record_processor }
|
|
103
|
+
records = nil unless records && step("logs processor") { sdk_logger_provider.add_log_record_processor(records) }
|
|
104
|
+
errors = !error_spans? && step("Rails.error") { @errors.subscribe(ErrorSpans.new) }
|
|
105
|
+
step("at_exit") { at_exit { [ spans, records ].compact.each { |processor| processor.shutdown(timeout: EXIT_TIMEOUT) } } }
|
|
106
|
+
|
|
107
|
+
attributes = provider.resource.attribute_enumerator.to_h
|
|
108
|
+
name = attributes["service.name"]
|
|
109
|
+
environment = attributes["deployment.environment.name"] || attributes["deployment.environment"]
|
|
110
|
+
@line = "Packrat: sending a copy of the app's #{signals(records)} for #{name}/#{environment} to #{@config.url}"
|
|
111
|
+
@line += "; Rails.error is now recorded on spans, which the app's own exporter also receives" if errors
|
|
112
|
+
if name.nil? || name.start_with?("unknown_service")
|
|
113
|
+
@line += ". Warning: the app's OpenTelemetry resource has no service.name, and the setup screen looks for the app by name"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Every step fails on its own: a warning that names it, and boot goes on. LoadError is not a
|
|
118
|
+
# StandardError, and a missing gem is the likely failure.
|
|
119
|
+
def step(name)
|
|
120
|
+
yield || true
|
|
121
|
+
rescue StandardError, LoadError => error
|
|
122
|
+
failed(name, error)
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def failed(name, error)
|
|
127
|
+
text = Packrat.redact(error.message.lines.first.to_s.strip, @config.key)
|
|
128
|
+
message = "Packrat: #{name} failed (#{error.class}: #{text}); the app runs without it"
|
|
129
|
+
@warnings << message
|
|
130
|
+
@logger.warn(message)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Required here rather than at the top of the gem: the logs SDK prepends a hook to
|
|
134
|
+
# SDK::Configurator, and an app that configures OpenTelemetry itself would then get a logger
|
|
135
|
+
# provider exporting to localhost:4318 that it never asked for.
|
|
136
|
+
def load_logs
|
|
137
|
+
require "opentelemetry-logs-sdk"
|
|
138
|
+
require "opentelemetry-exporter-otlp-logs"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def span_processor
|
|
142
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
143
|
+
ScrubbingSpanExporter.new(OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: @config.traces_endpoint, headers: @config.headers))
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_record_processor
|
|
148
|
+
OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(
|
|
149
|
+
ScrubbingLogExporter.new(OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(endpoint: @config.logs_endpoint, headers: @config.headers))
|
|
150
|
+
)
|
|
151
|
+
rescue NameError => error
|
|
152
|
+
raise unless error.name == :Utilities
|
|
153
|
+
|
|
154
|
+
# The logs exporter from 0.6 calls Common::Utilities, which otlp-common has from 0.3, and still
|
|
155
|
+
# declares "~> 0.2"; Bundler keeps an older lock. A bound in the gemspec would need Ruby 3.3.
|
|
156
|
+
raise NameError, "#{error.message}; run bundle update opentelemetry-exporter-otlp-common"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def configure_sdk(spans, records)
|
|
160
|
+
OpenTelemetry::SDK.configure do |sdk|
|
|
161
|
+
sdk.resource = OpenTelemetry::SDK::Resources::Resource.create(@config.resource_attributes)
|
|
162
|
+
sdk.add_span_processor(spans)
|
|
163
|
+
sdk.add_log_record_processor(records) if records
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def use_sampler
|
|
168
|
+
OpenTelemetry.tracer_provider.sampler =
|
|
169
|
+
OpenTelemetry::SDK::Trace::Samplers.parent_based(root: RootKindSampler.new)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def install(name)
|
|
173
|
+
raise ArgumentError, "the #{name} instrumentation must load before boot ends, which Packrat does not do" if BOOT_ONLY.include?(name)
|
|
174
|
+
|
|
175
|
+
require "opentelemetry-instrumentation-#{PREREQUISITES[name]}" if PREREQUISITES[name]
|
|
176
|
+
require "opentelemetry-instrumentation-#{name}"
|
|
177
|
+
skip_railtie(name) if %w[action_pack action_view].include?(name)
|
|
178
|
+
return unless instrumentation(name).install(OPTIONS.fetch(name, {}))
|
|
179
|
+
|
|
180
|
+
subscribe_action_view if name == "action_view"
|
|
181
|
+
|
|
182
|
+
prerequisite = PREREQUISITES[name]
|
|
183
|
+
@installed << prerequisite if prerequisite && !@installed.include?(prerequisite) && instrumentation(prerequisite).installed?
|
|
184
|
+
@installed << name
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Action Pack's install loads a Railtie whose one job is to put the Rack span in the middleware
|
|
188
|
+
# stack. At the end of boot the stack is frozen, so loading it raises; Packrat::RackSpan does
|
|
189
|
+
# that job, and the file is marked as loaded instead. Action View's is skipped too: see
|
|
190
|
+
# subscribe_action_view.
|
|
191
|
+
def skip_railtie(name)
|
|
192
|
+
_, path = $LOAD_PATH.resolve_feature_path("opentelemetry/instrumentation/#{name}/railtie")
|
|
193
|
+
path = File.realpath(path)
|
|
194
|
+
$LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Action View's Railtie subscribes in config.after_initialize. Loaded while Packrat's own
|
|
198
|
+
# after_initialize block runs, that block runs at once and again when Rails reaches it in the
|
|
199
|
+
# list it is walking. Two subscribers share one payload key, so every render left a context
|
|
200
|
+
# attached and each later request on the thread became a child of the last.
|
|
201
|
+
def subscribe_action_view
|
|
202
|
+
instrumentation("active_support").install({})
|
|
203
|
+
action_view = instrumentation("action_view")
|
|
204
|
+
formatter = OpenTelemetry::Instrumentation::ActiveSupport::LEGACY_NAME_FORMATTER if action_view.config[:legacy_span_names]
|
|
205
|
+
ACTION_VIEW_EVENTS.each do |event|
|
|
206
|
+
OpenTelemetry::Instrumentation::ActiveSupport.subscribe(
|
|
207
|
+
action_view.tracer, event, action_view.config[:notification_payload_transform],
|
|
208
|
+
action_view.config[:disallowed_notification_payload_keys], span_name_formatter: formatter
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def instrumentation(name)
|
|
214
|
+
namespace = CLASSES[name] || begin
|
|
215
|
+
constant = OpenTelemetry::Instrumentation.constants.find { |found| found.to_s.downcase == name.delete("_") }
|
|
216
|
+
"OpenTelemetry::Instrumentation::#{constant || raise(NameError, "no instrumentation named #{name}")}"
|
|
217
|
+
end
|
|
218
|
+
Object.const_get("#{namespace}::Instrumentation").instance
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def shutdown_providers
|
|
222
|
+
OpenTelemetry.tracer_provider.shutdown(timeout: EXIT_TIMEOUT)
|
|
223
|
+
OpenTelemetry.logger_provider.shutdown(timeout: EXIT_TIMEOUT) if sdk_logger_provider
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def sdk_logger_provider
|
|
227
|
+
return unless defined?(OpenTelemetry::SDK::Logs::LoggerProvider) && OpenTelemetry.respond_to?(:logger_provider)
|
|
228
|
+
|
|
229
|
+
provider = OpenTelemetry.logger_provider
|
|
230
|
+
provider if provider.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A logger provider nothing writes to is common: the logs SDK makes one whenever it is loaded,
|
|
234
|
+
# and an app may send its log lines through a provider of its own.
|
|
235
|
+
def logger_instrumented?
|
|
236
|
+
defined?(OpenTelemetry::Instrumentation::Logger::Instrumentation) &&
|
|
237
|
+
OpenTelemetry::Instrumentation::Logger::Instrumentation.instance.installed?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ActiveSupport::ErrorReporter keeps its subscribers private.
|
|
241
|
+
def error_spans?
|
|
242
|
+
Array(@errors.instance_variable_get(:@subscribers)).any? { |subscriber| subscriber.class.name.to_s.end_with?("ErrorSpans") }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def signals(records)
|
|
246
|
+
records ? "traces and logs" : "traces"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Packrat
|
|
2
|
+
# The Rack instrumentation is middleware, and the stack is built before the end of boot, where
|
|
3
|
+
# Packrat decides whether it configures OpenTelemetry at all. So the Railtie puts this in early,
|
|
4
|
+
# and it becomes the Rack span or passes every request straight through once that is decided.
|
|
5
|
+
class RackSpan
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
@lock = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
(@inner || inner).call(env)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def inner
|
|
18
|
+
return @app unless Packrat.setup
|
|
19
|
+
|
|
20
|
+
@lock.synchronize { @inner ||= Packrat.setup.rack(@app) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module Packrat
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "packrat.middleware" do |app|
|
|
6
|
+
Packrat.guard("middleware") do
|
|
7
|
+
app.config.middleware.insert_before(0, RackSpan) if Packrat.config.allowed? && !Otel.datadog?
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The decision must come after every after_initialize block, so that an app that configures
|
|
12
|
+
# OpenTelemetry late has done so. to_prepare runs after config/initializers registered theirs, so
|
|
13
|
+
# a block registered here is the last. An initializer declared after: :finisher_hook is not the
|
|
14
|
+
# same thing: the sort pulls the whole Finisher ahead of config/initializers. to_prepare runs
|
|
15
|
+
# again on every reload in development, hence the flag.
|
|
16
|
+
config.to_prepare do
|
|
17
|
+
next if Packrat.registered
|
|
18
|
+
|
|
19
|
+
Packrat.registered = true
|
|
20
|
+
Rails.application.config.after_initialize { Packrat.guard("boot") { Packrat.start } }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative "packrat"
|
data/lib/packrat.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "opentelemetry/sdk"
|
|
3
|
+
require "opentelemetry/exporter/otlp"
|
|
4
|
+
require_relative "packrat/version"
|
|
5
|
+
require_relative "packrat/config"
|
|
6
|
+
require_relative "packrat/otel/scrubber"
|
|
7
|
+
require_relative "packrat/otel/error_spans"
|
|
8
|
+
require_relative "packrat/otel/root_kind_sampler"
|
|
9
|
+
require_relative "packrat/otel"
|
|
10
|
+
require_relative "packrat/rack_span"
|
|
11
|
+
require_relative "packrat/railtie" if defined?(Rails::Railtie)
|
|
12
|
+
|
|
13
|
+
module Packrat
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :setup
|
|
16
|
+
attr_accessor :registered
|
|
17
|
+
|
|
18
|
+
def config
|
|
19
|
+
@config ||= Config.new(
|
|
20
|
+
env: ENV, rails_env: Rails.env, app_name: Rails.application.class.module_parent_name.underscore
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Packrat.configure { |config| config.ingest_key = "..." }, from config/initializers/packrat.rb.
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An app adds to what the gem configured with ActiveSupport.on_load(:packrat) { |setup| ... }.
|
|
30
|
+
def start
|
|
31
|
+
@setup = Otel::Setup.new(config:, logger: Rails.logger, errors: Rails.error).call
|
|
32
|
+
ActiveSupport.run_load_hooks(:packrat, @setup)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The outermost catch: whatever goes wrong, the app boots without Packrat.
|
|
36
|
+
def guard(name)
|
|
37
|
+
yield
|
|
38
|
+
rescue StandardError, LoadError => error
|
|
39
|
+
key = (config.key rescue nil)
|
|
40
|
+
Rails.logger&.warn("Packrat: #{name} failed (#{error.class}: #{redact(error.message, key)}); the app runs without it")
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def redact(text, key)
|
|
45
|
+
key ? text.to_s.gsub(key, Config::REDACTED) : text.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: packrat-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Packrat Labs
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: opentelemetry-sdk
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: opentelemetry-exporter-otlp
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.26'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.26'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: opentelemetry-logs-sdk
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.1'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.1'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: opentelemetry-exporter-otlp-logs
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.1'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.1'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: opentelemetry-instrumentation-rack
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.24'
|
|
90
|
+
type: :runtime
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.24'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: opentelemetry-instrumentation-action_pack
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.9'
|
|
104
|
+
type: :runtime
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.9'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: opentelemetry-instrumentation-active_job
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0.7'
|
|
118
|
+
type: :runtime
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0.7'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: opentelemetry-instrumentation-active_record
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '0.7'
|
|
132
|
+
type: :runtime
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '0.7'
|
|
139
|
+
- !ruby/object:Gem::Dependency
|
|
140
|
+
name: opentelemetry-instrumentation-net_http
|
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '0.22'
|
|
146
|
+
type: :runtime
|
|
147
|
+
prerelease: false
|
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0.22'
|
|
153
|
+
- !ruby/object:Gem::Dependency
|
|
154
|
+
name: opentelemetry-instrumentation-pg
|
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
|
156
|
+
requirements:
|
|
157
|
+
- - ">="
|
|
158
|
+
- !ruby/object:Gem::Version
|
|
159
|
+
version: '0.27'
|
|
160
|
+
type: :runtime
|
|
161
|
+
prerelease: false
|
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
163
|
+
requirements:
|
|
164
|
+
- - ">="
|
|
165
|
+
- !ruby/object:Gem::Version
|
|
166
|
+
version: '0.27'
|
|
167
|
+
- !ruby/object:Gem::Dependency
|
|
168
|
+
name: opentelemetry-instrumentation-mysql2
|
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
|
170
|
+
requirements:
|
|
171
|
+
- - ">="
|
|
172
|
+
- !ruby/object:Gem::Version
|
|
173
|
+
version: '0.27'
|
|
174
|
+
type: :runtime
|
|
175
|
+
prerelease: false
|
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
177
|
+
requirements:
|
|
178
|
+
- - ">="
|
|
179
|
+
- !ruby/object:Gem::Version
|
|
180
|
+
version: '0.27'
|
|
181
|
+
- !ruby/object:Gem::Dependency
|
|
182
|
+
name: opentelemetry-instrumentation-sidekiq
|
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
|
184
|
+
requirements:
|
|
185
|
+
- - ">="
|
|
186
|
+
- !ruby/object:Gem::Version
|
|
187
|
+
version: '0.25'
|
|
188
|
+
type: :runtime
|
|
189
|
+
prerelease: false
|
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
191
|
+
requirements:
|
|
192
|
+
- - ">="
|
|
193
|
+
- !ruby/object:Gem::Version
|
|
194
|
+
version: '0.25'
|
|
195
|
+
- !ruby/object:Gem::Dependency
|
|
196
|
+
name: opentelemetry-instrumentation-action_view
|
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
|
198
|
+
requirements:
|
|
199
|
+
- - ">="
|
|
200
|
+
- !ruby/object:Gem::Version
|
|
201
|
+
version: '0.7'
|
|
202
|
+
type: :runtime
|
|
203
|
+
prerelease: false
|
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
205
|
+
requirements:
|
|
206
|
+
- - ">="
|
|
207
|
+
- !ruby/object:Gem::Version
|
|
208
|
+
version: '0.7'
|
|
209
|
+
- !ruby/object:Gem::Dependency
|
|
210
|
+
name: opentelemetry-instrumentation-redis
|
|
211
|
+
requirement: !ruby/object:Gem::Requirement
|
|
212
|
+
requirements:
|
|
213
|
+
- - ">="
|
|
214
|
+
- !ruby/object:Gem::Version
|
|
215
|
+
version: '0.25'
|
|
216
|
+
type: :runtime
|
|
217
|
+
prerelease: false
|
|
218
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
219
|
+
requirements:
|
|
220
|
+
- - ">="
|
|
221
|
+
- !ruby/object:Gem::Version
|
|
222
|
+
version: '0.25'
|
|
223
|
+
- !ruby/object:Gem::Dependency
|
|
224
|
+
name: opentelemetry-instrumentation-aws_sdk
|
|
225
|
+
requirement: !ruby/object:Gem::Requirement
|
|
226
|
+
requirements:
|
|
227
|
+
- - ">="
|
|
228
|
+
- !ruby/object:Gem::Version
|
|
229
|
+
version: '0.5'
|
|
230
|
+
type: :runtime
|
|
231
|
+
prerelease: false
|
|
232
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
233
|
+
requirements:
|
|
234
|
+
- - ">="
|
|
235
|
+
- !ruby/object:Gem::Version
|
|
236
|
+
version: '0.5'
|
|
237
|
+
- !ruby/object:Gem::Dependency
|
|
238
|
+
name: opentelemetry-instrumentation-concurrent_ruby
|
|
239
|
+
requirement: !ruby/object:Gem::Requirement
|
|
240
|
+
requirements:
|
|
241
|
+
- - ">="
|
|
242
|
+
- !ruby/object:Gem::Version
|
|
243
|
+
version: '0.21'
|
|
244
|
+
type: :runtime
|
|
245
|
+
prerelease: false
|
|
246
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
247
|
+
requirements:
|
|
248
|
+
- - ">="
|
|
249
|
+
- !ruby/object:Gem::Version
|
|
250
|
+
version: '0.21'
|
|
251
|
+
- !ruby/object:Gem::Dependency
|
|
252
|
+
name: opentelemetry-instrumentation-rake
|
|
253
|
+
requirement: !ruby/object:Gem::Requirement
|
|
254
|
+
requirements:
|
|
255
|
+
- - ">="
|
|
256
|
+
- !ruby/object:Gem::Version
|
|
257
|
+
version: '0.2'
|
|
258
|
+
type: :runtime
|
|
259
|
+
prerelease: false
|
|
260
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
261
|
+
requirements:
|
|
262
|
+
- - ">="
|
|
263
|
+
- !ruby/object:Gem::Version
|
|
264
|
+
version: '0.2'
|
|
265
|
+
- !ruby/object:Gem::Dependency
|
|
266
|
+
name: opentelemetry-instrumentation-logger
|
|
267
|
+
requirement: !ruby/object:Gem::Requirement
|
|
268
|
+
requirements:
|
|
269
|
+
- - ">="
|
|
270
|
+
- !ruby/object:Gem::Version
|
|
271
|
+
version: '0.1'
|
|
272
|
+
type: :runtime
|
|
273
|
+
prerelease: false
|
|
274
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
275
|
+
requirements:
|
|
276
|
+
- - ">="
|
|
277
|
+
- !ruby/object:Gem::Version
|
|
278
|
+
version: '0.1'
|
|
279
|
+
description:
|
|
280
|
+
email:
|
|
281
|
+
executables: []
|
|
282
|
+
extensions: []
|
|
283
|
+
extra_rdoc_files: []
|
|
284
|
+
files:
|
|
285
|
+
- LICENSE
|
|
286
|
+
- README.md
|
|
287
|
+
- lib/generators/packrat/packrat_generator.rb
|
|
288
|
+
- lib/generators/packrat/templates/initializer.rb.tt
|
|
289
|
+
- lib/packrat-rails.rb
|
|
290
|
+
- lib/packrat.rb
|
|
291
|
+
- lib/packrat/config.rb
|
|
292
|
+
- lib/packrat/otel.rb
|
|
293
|
+
- lib/packrat/otel/error_spans.rb
|
|
294
|
+
- lib/packrat/otel/root_kind_sampler.rb
|
|
295
|
+
- lib/packrat/otel/scrubber.rb
|
|
296
|
+
- lib/packrat/rack_span.rb
|
|
297
|
+
- lib/packrat/railtie.rb
|
|
298
|
+
- lib/packrat/version.rb
|
|
299
|
+
homepage: https://packrat.sh/docs/ruby-on-rails
|
|
300
|
+
licenses:
|
|
301
|
+
- MIT
|
|
302
|
+
metadata:
|
|
303
|
+
source_code_uri: https://github.com/packratlabs/packrat-rails
|
|
304
|
+
post_install_message:
|
|
305
|
+
rdoc_options: []
|
|
306
|
+
require_paths:
|
|
307
|
+
- lib
|
|
308
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
309
|
+
requirements:
|
|
310
|
+
- - ">="
|
|
311
|
+
- !ruby/object:Gem::Version
|
|
312
|
+
version: '3.1'
|
|
313
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
314
|
+
requirements:
|
|
315
|
+
- - ">="
|
|
316
|
+
- !ruby/object:Gem::Version
|
|
317
|
+
version: '0'
|
|
318
|
+
requirements: []
|
|
319
|
+
rubygems_version: 3.5.22
|
|
320
|
+
signing_key:
|
|
321
|
+
specification_version: 4
|
|
322
|
+
summary: OpenTelemetry for Rails, configured to send to Packrat with one key
|
|
323
|
+
test_files: []
|