foam-otel 3.0.0.alpha.5 → 3.0.0.alpha.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34ec502fdc38ac38099bba65f1fc37c91f1edb79476bff598c6a0dda7245fcf0
4
- data.tar.gz: 9596d8c1d746b66103b87587f5c52006e024d0498d93816912ed916014e563e4
3
+ metadata.gz: 4458e9959661525a33f70692beaeba2f32df91a68b01b7e611ec2ae2e2901b39
4
+ data.tar.gz: bb9127c1d897dec9f1a8268a338382fa1ffdfe772918abdf920d413ff10b986e
5
5
  SHA512:
6
- metadata.gz: 85207e2233f8a986bb874f68215960f20216e010978e5d1355808ec2ed6bfb44f19c9b8f8947cc3164cca65864326dab8b5ef95fb8ef6c37a1150fe06923ae2a
7
- data.tar.gz: 3a3bc463b06b32e1d1bd4b6c439e5f8a92b031859301f8681e78a32e4e1db655ef73d4439a0755e336abc9fc1b068bca48049172046732da4adb514eb12c00cb
6
+ metadata.gz: d10e3d644eee2036278ae2c5310fc1afd33ec9acda7b27189247a0cb1dd88e1ebc433538cca528567278f97fa9103319a2e573d97f2ca201e24338b50f1c2764
7
+ data.tar.gz: 96eefe317fbf6eb8906e30242618fe69d90d303f0b3d2e74ceb008c968adc94884a39279b0a887e8e1ab7e5c1b4595dfd8aaf6e31eaeca123b451a002438f52f
data/README.md CHANGED
@@ -134,6 +134,7 @@ Foam.init(
134
134
  disable_log_sending: false,
135
135
  network_capture: :basic,
136
136
  ignored_outbound_hosts: [],
137
+ ignored_outbound_headers: [],
137
138
  redact: nil,
138
139
  before_send: nil
139
140
  )
@@ -150,7 +151,8 @@ Foam.init(
150
151
  - `additional_span_processors`, `additional_log_record_processors`, `additional_metric_readers` - the app's own OpenTelemetry components, added to Foam's providers after Foam's own exporters.
151
152
  - `additional_resource_attributes` - attributes merged into the resource of every signal. `service.name`, `service.version`, and `deployment.environment.name` come from the options above and `service.instance.id` from Foam; a value for one of them here is reported on stderr and ignored.
152
153
  - `additional_instrumentations` - instrumentations to install next to the bundle. An entry is an instrumentation's registry name, which is its class name without the trailing `::Instrumentation` (`"OpenTelemetry::Instrumentation::Logger"`), or a `[name, config]` pair to install it with options (`["OpenTelemetry::Instrumentation::PG", {db_statement: :include}]`). Require the instrumentation's gem before `init`; an entry whose instrumentation is not loaded fails `init`. An entry naming a bundled instrumentation replaces Foam's configuration for it.
153
- - `ignored_outbound_hosts` - skips tracing of outbound HTTP requests to these hostnames. The endpoint host is always skipped. Use it for hosts whose traffic would drown the traces, such as a sidecar health check on `localhost` or another vendor's OTLP ingest host. Hosts are compared as written; `localhost` and `127.0.0.1` are different hosts. A request to a skipped host still carries a `traceparent` marked not sampled, so an OpenTelemetry service behind it records nothing for that request.
154
+ - `ignored_outbound_hosts` - skips tracing of outbound HTTP requests to these hosts, each written `host` or `host:port`. The endpoint host is always skipped. Use it for hosts whose traffic would drown the traces, such as a sidecar health check on `localhost`, another SDK's agent on `127.0.0.1:8126`, or another vendor's OTLP ingest host. Hosts are compared as written; `localhost` and `127.0.0.1` are different hosts, and an entry with a port matches that port only. A request to a skipped host still carries a `traceparent` marked not sampled, so an OpenTelemetry service behind it records nothing for that request.
155
+ - `ignored_outbound_headers` - skips tracing of outbound Net::HTTP requests carrying any of these request headers, compared case-insensitively. Another SDK's transport stamps its own header on every request to its backend, such as `Datadog-Meta-Tracer-Version`, which tells that traffic apart when it has no host to ignore, as over a Unix socket. A connection opened before the request, as `Net::HTTP.start` does, has its connect span before any header is seen, so over TCP ignore the host as well.
154
156
  - `disable_log_sending` - `true` stops shipping the app's `Logger` lines to Foam. `Foam.log` is unaffected. See Loggers.
155
157
  - `network_capture` - `:off`, `:basic`, or `:advanced`. Use `:advanced` unless security and compliance rule it out. See Network capture.
156
158
  - `redact` - keys to mask beyond the built-in list, as `{secrets: [...], pii: [...]}`. A value under a `secrets` key keeps its last four characters when it is 12 characters or longer; a value under a `pii` key is fully masked. See Redaction.
@@ -254,6 +256,8 @@ end
254
256
 
255
257
  `inject_trace_context` and `extract_trace_context` always use the W3C `traceparent`, `tracestate`, and `baggage` headers, whatever propagator holds the global slot. Foam does not read `OTEL_PROPAGATORS`. An app that needs a different propagator registers it before `init`, and Foam leaves it in place.
256
258
 
259
+ On the outbound requests Foam instruments, the propagation headers are written last, at the transport, after any other SDK's: Net::HTTP, HTTPClient, Excon, http.rb, Ethon, gRPC, and Sidekiq job payloads, and Faraday, RestClient, and Typhoeus through those. Another SDK's own propagation headers are left as written.
260
+
257
261
  ### `set_baggage(key, value)`
258
262
 
259
263
  Adds a key-value pair to the current context's baggage. Baggage travels with the trace context to every downstream service, where `baggage(key)` reads it; it is not written onto spans. The value lasts for the rest of the current scope, for example the rest of the request when called from a Rails controller, and is dropped when that scope ends. Outside any span or context scope the call is ignored and warned about once, so a value can never outlive the work it belongs to. No-op before `init`.
@@ -436,6 +440,8 @@ Covered:
436
440
 
437
441
  Not covered, since they do not build on `Logger`: Semantic Logger, `logging`, log4r, yell, and Puma's own server lines. Use `Foam.log` for a record from these. Semantic Logger also ships an `:open_telemetry` appender. `puts` and direct writes to `$stdout` or `$stderr` are never captured, nor is a `Logger` whose device is `nil` or `File::NULL`.
438
442
 
443
+ Lines another SDK writes through its own `Logger` are shipped like the app's; `before_send` can drop them.
444
+
439
445
  If another SDK owns logs, set `disable_log_sending: true` so records do not arrive twice, and attach `create_foam_ingest_log_record_processor` to that SDK's provider. `Foam.log` delivers either way.
440
446
 
441
447
  Secrets in a log line are masked before export. See Redaction.
@@ -459,6 +465,27 @@ Thread.new do
459
465
  end
460
466
  ```
461
467
 
468
+ ## Other backends installed
469
+
470
+ An app may already run another vendor's SDK, such as Datadog, which records its own telemetry and sends it to that vendor. Do not change its configuration.
471
+
472
+ Call `init` after configuring the existing SDK. If the app already configures OpenTelemetry itself with `OpenTelemetry::SDK.configure`, instead attach the ingest helpers to the app's providers and do not call `init`; see `create_foam_ingest_span_processor` under Public API.
473
+
474
+ ### Datadog
475
+
476
+ Datadog sends its data to its agent over HTTP, which Foam would otherwise record as spans. Pass the agent's address and Datadog's request header to `init`. This works whether the agent is on TCP or a Unix socket:
477
+
478
+ ```ruby
479
+ agent = Datadog::Core::Configuration::AgentSettingsResolver.call(Datadog.configuration)
480
+ agent_hosts = agent.uds_path ? [] : ["#{agent.hostname}:#{agent.port}"]
481
+
482
+ Foam.init(
483
+ name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
484
+ ignored_outbound_hosts: agent_hosts,
485
+ ignored_outbound_headers: ["Datadog-Meta-Tracer-Version"]
486
+ )
487
+ ```
488
+
462
489
  ## Redaction
463
490
 
464
491
  Foam masks the values of sensitive keys in everything it exports: span, span event, and span link attributes; log bodies and attributes; metric data point attributes; and resource attributes. A built-in list of credential and personal-data keys always applies, matched by exact name or by a sensitive segment of the key (`legacy_api_key_2`, `stripeToken`). Keys match after normalization: case, `-`, `.`, and camelCase boundaries are folded, so `apiKey`, `api-key`, and `API_KEY` are one key. Header attributes match on the header name. SQL statements arrive from the database instrumentations with every literal replaced with `?` (see Instrumentation) and are redacted like any other string. Captured HTTP bodies are masked by content type with the same keys, with their framing kept. When masking a body fails, the whole body is replaced with `********`.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Drops the spans of a request carrying one of the ignored headers: the request runs under a
7
+ # flagged context, and the sampler drops every span started there. Another SDK's transport stamps
8
+ # its header on every request to its backend, whether over TCP or a Unix socket. A connection
9
+ # opened before the request has its connect span before any header is seen; ignoring the host
10
+ # covers that one.
11
+ module IgnoredHeaders
12
+ CONTEXT_KEY = OpenTelemetry::Context.create_key("foam-ignored-request")
13
+
14
+ class << self
15
+ attr_reader :names
16
+
17
+ def install(names)
18
+ @names = names
19
+ ::Net::HTTP.prepend(self) unless names.empty?
20
+ end
21
+ end
22
+
23
+ def request(req, body = nil, &block)
24
+ return super unless IgnoredHeaders.names.any? { |name| req.key?(name) }
25
+
26
+ OpenTelemetry::Context.with_value(CONTEXT_KEY, true) { super }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -6,26 +6,32 @@ require "opentelemetry-semantic_conventions"
6
6
  module Foam
7
7
  module Otel
8
8
  module Internal
9
- # The tracer provider's sampler: drops every outbound span to an ignored host and leaves the rest
10
- # to the sampler built from sample_rate. A dropped span never records, so no processor sees it.
9
+ # The tracer provider's sampler: drops every outbound span to an ignored host and every span under
10
+ # an ignored request, and leaves the rest to the sampler built from sample_rate. A dropped span
11
+ # never records, so no processor sees it.
11
12
  class IgnoredHosts
12
- # Where the client instrumentations put the host when the span starts.
13
+ # Where the client instrumentations put the host and port when the span starts.
13
14
  HOST_KEYS = [
14
15
  OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS,
15
16
  OpenTelemetry::SemConv::Incubating::NET::NET_PEER_NAME
16
17
  ].freeze
18
+ PORT_KEYS = [
19
+ OpenTelemetry::SemConv::SERVER::SERVER_PORT,
20
+ OpenTelemetry::SemConv::Incubating::NET::NET_PEER_PORT
21
+ ].freeze
17
22
 
18
23
  # Skipped: server spans carry the local host in server.address.
19
24
  INBOUND_KINDS = %i[server consumer].freeze
20
25
 
21
- # Hosts as written, no resolution: localhost and 127.0.0.1 are different hosts.
26
+ # Hosts as written, no resolution: localhost and 127.0.0.1 are different hosts. An entry
27
+ # written host:port matches that port only.
22
28
  def initialize(hosts, delegate)
23
29
  @hosts = Set.new(hosts)
24
30
  @delegate = delegate
25
31
  end
26
32
 
27
33
  def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:)
28
- return drop(parent_context) if ignored?(kind, attributes)
34
+ return drop(parent_context) if parent_context.value(IgnoredHeaders::CONTEXT_KEY) || ignored?(kind, attributes)
29
35
 
30
36
  @delegate.should_sample?(
31
37
  trace_id: trace_id, parent_context: parent_context, links: links,
@@ -52,16 +58,20 @@ module Foam
52
58
  def ignored?(kind, attributes)
53
59
  return false if attributes.nil? || INBOUND_KINDS.include?(kind)
54
60
 
55
- # The host the client instrumentation set, if it set one.
61
+ # The host and port the client instrumentation set, if it set them.
56
62
  host = attributes.values_at(*HOST_KEYS).compact.first
63
+ port = attributes.values_at(*PORT_KEYS).compact.first
57
64
  # The rest-client gem's instrumentation sets no host, only the URL.
58
- host ||= url_host(attributes[OpenTelemetry::SemConv::URL::URL_FULL])
65
+ url = parsed(attributes[OpenTelemetry::SemConv::URL::URL_FULL])
66
+ host ||= url&.host
67
+ port ||= url&.port
68
+ return false if host.nil?
59
69
 
60
- !host.nil? && @hosts.include?(host)
70
+ @hosts.include?(host) || @hosts.include?("#{host}:#{port}")
61
71
  end
62
72
 
63
- def url_host(url)
64
- URI(url).host if url
73
+ def parsed(url)
74
+ URI(url) if url
65
75
  rescue URI::Error
66
76
  nil
67
77
  end
@@ -73,6 +73,23 @@ module Foam
73
73
  begin
74
74
  ForkHooks.attach
75
75
 
76
+ # Where another SDK's patch and an instrumentation's define the same helper on a class,
77
+ # each keeps its own, whichever of the two came first.
78
+ Safely.call("patch resolver") { PatchResolver.install }
79
+
80
+ # Drops the spans of the requests carrying an ignored header, in every capture mode.
81
+ IgnoredHeaders.install(options[:ignored_outbound_headers])
82
+
83
+ # Writes the W3C headers last on the requests Foam instruments, in every capture mode.
84
+ Reinject.install
85
+
86
+ # The logger instrumentation emits a record from inside every Logger call, and that emit
87
+ # reads the current OpenTelemetry context. An SDK that supplies its own context can raise
88
+ # there while it boots, which would make every app log line raise. LoggerBridge is
89
+ # prepended after the instrumentation's patch, so it sits in front of it and drops just
90
+ # the record whose emit failed.
91
+ ::Logger.prepend(LoggerBridge) if Instrumentations.instance(Instrumentations::LOGGER).installed?
92
+
76
93
  # The client patches set the safe headers on every client span and, in advanced mode,
77
94
  # capture bodies too. Bodies are redacted by the same rules as attributes, so only
78
95
  # advanced builds a body redactor; its presence is what switches body capture on.
@@ -229,8 +246,8 @@ module Foam
229
246
 
230
247
  # The caller's hosts plus the endpoint host, so Foam's own exports never become client spans.
231
248
  def ignored_hosts(hosts)
232
- unless hosts.is_a?(Enumerable)
233
- raise ArgumentError, "ignored_outbound_hosts must be an array of hostnames, got #{hosts.inspect}"
249
+ unless hosts.is_a?(Enumerable) && hosts.all? { |host| host.is_a?(String) && !Internal.blank?(host) }
250
+ raise ArgumentError, "ignored_outbound_hosts must be an array of hostnames, each with an optional :port, got #{hosts.inspect}"
234
251
  end
235
252
 
236
253
  Set.new(hosts) << URI(Endpoint.current).host
@@ -239,7 +256,7 @@ module Foam
239
256
  # The first option holding a value init cannot use, described as "<option> must be ..., got
240
257
  # <value>", or nil when every option is usable.
241
258
  def invalid_option(
242
- sample_rate:, disable_log_sending:, network_capture:, redact:, before_send:, **
259
+ sample_rate:, disable_log_sending:, network_capture:, ignored_outbound_headers:, redact:, before_send:, **
243
260
  )
244
261
  unless sample_rate.is_a?(Numeric) && sample_rate.finite? && (0..1).cover?(sample_rate)
245
262
  return "sample_rate must be a number from 0.0 to 1.0, got #{sample_rate.inspect}"
@@ -253,6 +270,10 @@ module Foam
253
270
  return "network_capture must be :off, :basic, or :advanced, got #{network_capture.inspect}"
254
271
  end
255
272
 
273
+ unless ignored_outbound_headers.is_a?(Enumerable) && ignored_outbound_headers.all? { |name| name.is_a?(String) && !Internal.blank?(name) }
274
+ return "ignored_outbound_headers must be an array of header names, got #{ignored_outbound_headers.inspect}"
275
+ end
276
+
256
277
  if (problem = Redactor.invalid_option(redact) || BeforeSend.invalid_option(before_send))
257
278
  return problem
258
279
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Guards the emit the logger instrumentation runs inside every Logger line, so a failing emit
7
+ # drops that one record instead of raising into the app. Prepended on Logger by init once that
8
+ # instrumentation installed, so it sits ahead of the patch.
9
+ module LoggerBridge
10
+ private
11
+
12
+ # Another SDK's OpenTelemetry bridge can raise from Context.current while that SDK boots.
13
+ def emit_to_otel(severity, datetime, body)
14
+ Safely.call("logger bridge") { super }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Keeps each patch on a class calling its own helper when another patch defines one of the same
7
+ # name. Without this, Ruby would resolve the bare call from the front of the ancestor chain,
8
+ # whichever module made it, so one patch would always reach the other's helper.
9
+ module PatchResolver
10
+ # The namespace of the instrumentation patches init installs. A collision is resolved only
11
+ # when one of them is party to it; two unrelated gems fighting over a class are not Foam's to fix.
12
+ INSTRUMENTATION = "OpenTelemetry::Instrumentation::"
13
+
14
+ # Foam's own patches wrap a helper by calling super, so they are never party to a collision.
15
+ FOAM = "Foam::Otel::"
16
+
17
+ # The Thread.current key holding the patch whose method is running.
18
+ RUNNING = :foam_otel_running_patch
19
+
20
+ # Marks the modules the resolver prepends on the class, so a chain scan can leave them out.
21
+ module Dispatcher
22
+ attr_reader :names
23
+ end
24
+
25
+ # Marks the modules the resolver prepends on an owner.
26
+ module Marker
27
+ end
28
+
29
+ # Prepended on the class's singleton, so a module prepended later does not stay ahead.
30
+ module Watch
31
+ def prepend(*modules)
32
+ super
33
+ PatchResolver.assert(self)
34
+ self
35
+ end
36
+ end
37
+
38
+ class << self
39
+ # Watches every loaded class carrying an instrumentation patch and resolves what already
40
+ # collides on it. Runs after the instrumentations installed.
41
+ def install
42
+ patched_classes.each do |klass|
43
+ klass.singleton_class.prepend(Watch) unless klass.singleton_class.include?(Watch)
44
+ assert(klass)
45
+ end
46
+ end
47
+
48
+ def patched_classes
49
+ ObjectSpace.each_object(Class).select do |klass|
50
+ !klass.singleton_class? && patches(klass).any? { |mod| instrumentation?(mod) }
51
+ end
52
+ end
53
+
54
+ # Marks the owners and prepends a dispatcher for the helpers that collide on klass, unless
55
+ # the front of the chain already holds one for the same helpers. Warns when a collision is
56
+ # first resolved, once per helper.
57
+ def assert(klass)
58
+ modules = patches(klass)
59
+ names = collisions(klass, modules)
60
+ return if names.empty?
61
+
62
+ front = klass.ancestors.first
63
+ return if front.is_a?(Dispatcher) && front.names == names
64
+
65
+ owners = modules.select { |mod| names.any? { |name| defines?(mod, name) } }
66
+ if owners.map { |owner| mark(owner, names) }.any?
67
+ names.each do |name|
68
+ holders = owners.select { |mod| defines?(mod, name) }
69
+ Diagnostics.warn("#{klass}: #{name} is defined by #{holders.join(" and ")}; each patch keeps its own")
70
+ end
71
+ end
72
+ klass.prepend(dispatcher(names, owners))
73
+ end
74
+
75
+ # The helpers two or more of the modules define, an instrumentation patch among them, that
76
+ # nothing behind the modules defines. A method the class itself has is overridden on purpose
77
+ # and chains through super, as Net::HTTP#request does under two SDKs; a helper exists nowhere
78
+ # below the patches, so two definitions fight.
79
+ def collisions(klass, modules)
80
+ behind = klass.ancestors.drop_while { |ancestor| !ancestor.equal?(klass) }
81
+ counts = modules.flat_map { |mod| mod.instance_methods(false) + mod.private_instance_methods(false) }.tally
82
+ counts.filter_map do |name, count|
83
+ next if count < 2 || behind.any? { |ancestor| defines?(ancestor, name) }
84
+
85
+ name if modules.any? { |mod| instrumentation?(mod) && defines?(mod, name) }
86
+ end
87
+ end
88
+
89
+ # The modules prepended on klass, the resolver's own and Foam's left out.
90
+ def patches(klass)
91
+ klass.ancestors.take_while { |ancestor| !ancestor.equal?(klass) }
92
+ .reject { |mod| mod.is_a?(Dispatcher) || mod.is_a?(Marker) || mod.name.to_s.start_with?(FOAM) }
93
+ end
94
+
95
+ def instrumentation?(mod)
96
+ mod.name.to_s.start_with?(INSTRUMENTATION)
97
+ end
98
+
99
+ def defines?(mod, name)
100
+ mod.method_defined?(name, false) || mod.private_method_defined?(name, false)
101
+ end
102
+
103
+ # Records the owner as the running patch for the duration of each of its methods, the
104
+ # helpers themselves aside. Prepended on the owner module, the wrapper sits right ahead of it
105
+ # in every class that prepended the owner. Returns whether the owner was marked now rather
106
+ # than earlier.
107
+ def mark(owner, names)
108
+ return false if owner.ancestors.any? { |mod| mod.is_a?(Marker) }
109
+
110
+ marker = Module.new do
111
+ extend Marker
112
+
113
+ methods = owner.instance_methods(false) + owner.private_instance_methods(false) - names
114
+ methods.each do |method_name|
115
+ define_method(method_name) do |*args, **options, &block|
116
+ previous = Thread.current[RUNNING]
117
+ Thread.current[RUNNING] = owner
118
+ super(*args, **options, &block)
119
+ ensure
120
+ Thread.current[RUNNING] = previous
121
+ end
122
+ private method_name if owner.private_method_defined?(method_name, false)
123
+ end
124
+ end
125
+ owner.prepend(marker)
126
+ true
127
+ end
128
+
129
+ # Answers each colliding helper with the running patch's own implementation. A call from
130
+ # outside every marked patch continues down the chain, where the first owner's helper
131
+ # answers as it would without the resolver. A helper every owner keeps private stays private.
132
+ def dispatcher(names, owners)
133
+ Module.new do
134
+ extend Dispatcher
135
+
136
+ @names = names
137
+ names.each do |name|
138
+ holders = owners.select { |owner| PatchResolver.defines?(owner, name) }
139
+ implementations = holders.to_h { |owner| [owner, owner.instance_method(name)] }
140
+ define_method(name) do |*args, **options, &block|
141
+ implementation = implementations[Thread.current[RUNNING]]
142
+ implementation ? implementation.bind_call(self, *args, **options, &block) : super(*args, **options, &block)
143
+ end
144
+ private name if holders.all? { |owner| owner.private_method_defined?(name, false) }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Handles another backend SDK in the same process, such as Datadog. Both SDKs write trace
7
+ # headers on the outbound requests, and whichever writes last owns traceparent. Foam writes
8
+ # its W3C headers again at the transport, so traceparent always carries Foam's trace. The
9
+ # other SDK's own headers (such as Datadog's x-datadog-*) are left as written, so each
10
+ # backend continues its own trace downstream. Installed by init in every capture mode.
11
+ module Reinject
12
+ module_function
13
+
14
+ # The global propagator, the one the instrumentation used. Without a span there is nothing
15
+ # the instrumentation wrote, so nothing is written here either.
16
+ def call(carrier, context: OpenTelemetry::Context.current)
17
+ return if OwnRequest.current? || !OpenTelemetry::Trace.current_span(context).context.valid?
18
+
19
+ OpenTelemetry.propagation.inject(carrier, context: context)
20
+ end
21
+
22
+ module NetHttp
23
+ private
24
+
25
+ def transport_request(req)
26
+ Safely.call("propagation") { Reinject.call(req) }
27
+ super
28
+ end
29
+ end
30
+
31
+ module HttpClient
32
+ def query(req)
33
+ Safely.call("propagation") { Reinject.call(req.header) }
34
+ super
35
+ end
36
+ end
37
+
38
+ module Excon
39
+ def request_call(datum)
40
+ Safely.call("propagation") { Reinject.call(datum[:headers]) }
41
+ super
42
+ end
43
+ end
44
+
45
+ module HttpRb
46
+ def send_request(req)
47
+ Safely.call("propagation") { Reinject.call(req.headers) }
48
+ super
49
+ end
50
+ end
51
+
52
+ # Every header write reaches libcurl through this setter, the instrumentation's and any
53
+ # other SDK's alike, so the last one carries Foam's headers whichever it was.
54
+ module Ethon
55
+ def headers=(headers)
56
+ headers = (headers || {}).dup
57
+ Safely.call("propagation") { Reinject.call(headers, context: foam_ethon_context) }
58
+ super
59
+ end
60
+
61
+ private
62
+
63
+ # The instrumentation starts its span in @otel_span without making it current.
64
+ def foam_ethon_context
65
+ span = @otel_span if instance_variable_defined?(:@otel_span)
66
+ span ? OpenTelemetry::Trace.context_with_span(span) : OpenTelemetry::Context.current
67
+ end
68
+ end
69
+
70
+ # The client interceptors, where both SDKs inject, run outside these calls.
71
+ module Grpc
72
+ %i[request_response client_streamer server_streamer bidi_streamer].each do |name|
73
+ define_method(name) do |*args, metadata: {}, **rest, &block|
74
+ Safely.call("propagation") { Reinject.call(metadata) }
75
+ super(*args, metadata: metadata, **rest, &block)
76
+ end
77
+ end
78
+ end
79
+
80
+ # The chain's final block runs inside every client middleware, where both SDKs inject, with
81
+ # the producer span current.
82
+ module Sidekiq
83
+ # The client chain is invoked as (worker_class, job, queue, redis_pool), the server chain as
84
+ # (worker, job, queue).
85
+ def invoke(*args, &block)
86
+ return super unless args.size == 4
87
+
88
+ _worker_class, job, _queue, _redis_pool = args
89
+ super do
90
+ Safely.call("propagation") { Reinject.call(job) }
91
+ block.call
92
+ end
93
+ end
94
+ end
95
+
96
+ # The transport class each hook prepends onto, and the instrumentation that has to be
97
+ # installed for the hook to apply; Net::HTTP is patched without a check, since foam-otel
98
+ # loads it for its own exporters.
99
+ HOOKS = {
100
+ NetHttp => ["Net::HTTP", nil],
101
+ HttpClient => ["HTTPClient::Session", Clients::HttpClient::INSTRUMENTATION],
102
+ Excon => ["Excon::Connection", Clients::Excon::INSTRUMENTATION],
103
+ HttpRb => ["HTTP::Connection", "OpenTelemetry::Instrumentation::HTTP"],
104
+ Ethon => ["Ethon::Easy", "OpenTelemetry::Instrumentation::Ethon"],
105
+ Grpc => ["GRPC::ActiveCall", "OpenTelemetry::Instrumentation::Grpc"],
106
+ Sidekiq => ["Sidekiq::Middleware::Chain", "OpenTelemetry::Instrumentation::Sidekiq"]
107
+ }.freeze
108
+
109
+ def install
110
+ HOOKS.each do |hook, (klass, instrumentation)|
111
+ next unless instrumentation.nil? || Instrumentations.instance(instrumentation).installed?
112
+
113
+ Object.const_get(klass).prepend(hook)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foam
4
4
  module Otel
5
- VERSION = "3.0.0.alpha.5"
5
+ VERSION = "3.0.0.alpha.6"
6
6
  end
7
7
  end
data/lib/foam/otel.rb CHANGED
@@ -26,9 +26,13 @@ require_relative "otel/internal/aws_lambda"
26
26
  require_relative "otel/internal/rails_middleware"
27
27
  require_relative "otel/internal/before_send"
28
28
  require_relative "otel/internal/instrumentations"
29
+ require_relative "otel/internal/ignored_headers"
30
+ require_relative "otel/internal/logger_bridge"
31
+ require_relative "otel/internal/reinject"
29
32
  require_relative "otel/internal/ignored_hosts"
30
33
  require_relative "otel/internal/slots"
31
34
  require_relative "otel/internal/fork_hooks"
35
+ require_relative "otel/internal/patch_resolver"
32
36
  require_relative "otel/internal/resource"
33
37
  require_relative "otel/internal/init"
34
38
  require_relative "otel/internal/exporters"
@@ -58,6 +62,7 @@ module Foam
58
62
  disable_log_sending: false,
59
63
  network_capture: :basic,
60
64
  ignored_outbound_hosts: [],
65
+ ignored_outbound_headers: [],
61
66
  redact: nil,
62
67
  before_send: nil
63
68
  )
@@ -77,6 +82,7 @@ module Foam
77
82
  disable_log_sending: disable_log_sending,
78
83
  network_capture: network_capture,
79
84
  ignored_outbound_hosts: ignored_outbound_hosts,
85
+ ignored_outbound_headers: ignored_outbound_headers,
80
86
  redact: redact,
81
87
  before_send: before_send
82
88
  )
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.alpha.5
4
+ version: 3.0.0.alpha.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-exporter-otlp
@@ -176,21 +176,25 @@ files:
176
176
  - lib/foam/otel/internal/endpoint.rb
177
177
  - lib/foam/otel/internal/exporters.rb
178
178
  - lib/foam/otel/internal/fork_hooks.rb
179
+ - lib/foam/otel/internal/ignored_headers.rb
179
180
  - lib/foam/otel/internal/ignored_hosts.rb
180
181
  - lib/foam/otel/internal/ingest.rb
181
182
  - lib/foam/otel/internal/init.rb
182
183
  - lib/foam/otel/internal/instrumentations.rb
183
184
  - lib/foam/otel/internal/lifecycle.rb
185
+ - lib/foam/otel/internal/logger_bridge.rb
184
186
  - lib/foam/otel/internal/logs.rb
185
187
  - lib/foam/otel/internal/metrics.rb
186
188
  - lib/foam/otel/internal/otlp.rb
187
189
  - lib/foam/otel/internal/own_request.rb
190
+ - lib/foam/otel/internal/patch_resolver.rb
188
191
  - lib/foam/otel/internal/propagation.rb
189
192
  - lib/foam/otel/internal/rack/body_tee.rb
190
193
  - lib/foam/otel/internal/rack/input_tee.rb
191
194
  - lib/foam/otel/internal/rails_middleware.rb
192
195
  - lib/foam/otel/internal/redactor.rb
193
196
  - lib/foam/otel/internal/redactor/keys.rb
197
+ - lib/foam/otel/internal/reinject.rb
194
198
  - lib/foam/otel/internal/report.rb
195
199
  - lib/foam/otel/internal/resource.rb
196
200
  - lib/foam/otel/internal/safely.rb