foam-otel 3.0.0.alpha.9 → 3.0.0.alpha.11

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: 240906547c665dd40513921db5ab9f00d65febef81d40848faa91a1f5ae02e15
4
- data.tar.gz: 9a2c4c5045ba080c21b165e1a87836cd9288408343b049c293352162e9be7083
3
+ metadata.gz: a99c91ac48b87605a88f36e38e06ceddf80a6af6f029aad030203bc3d09603fb
4
+ data.tar.gz: 840a2d7cbf0baebf643c3f0422c310da434d3dc147c23e0c919792f36b4e3726
5
5
  SHA512:
6
- metadata.gz: f891d7d8a749ce8a9042f08183581ec43924445d21137b863619a912cd0be0bb516e340d01e2ed6e238356a3b61f3cedb5739b769b04e38213d785712616cb9f
7
- data.tar.gz: dd19f649f254cff7a00da71b14f158cc3d3d370a3affcc587ec86156d5439aaecd956564e106c54f0c0b2d229195065f69c03ab3eae9058459265ea479b71aaf
6
+ metadata.gz: b01ef004f8ee7e8638badb8ee4934b07df03a2f636ec61bbf6a0f1d77fae7e187ddb41dca30c0b0dc667e9a3a1c2d9792bb70fe7f4f38295c0e03e4474d40471
7
+ data.tar.gz: 666a66c3c6a5da5e8d3daf5c50b4536155538bfa321ce82bd6bfa3dcf97f407fac976b81999b50abdd4e0d7d50ac7e3172b55f30f1dca3afbbb223653ca3eea3
data/README.md CHANGED
@@ -227,7 +227,7 @@ Do not combine `init` and an ingest component for the same signal. The ingest co
227
227
 
228
228
  ### `inject_trace_context(carrier)`
229
229
 
230
- Writes the W3C `traceparent`, `tracestate`, and `baggage` headers for the current span into `carrier`, a hash of string keys.
230
+ Writes the W3C `traceparent`, `tracestate`, and `baggage` headers for the current span into `carrier`, a hash of string keys. A `tracestate` the carrier already holds is kept, since Foam adds no entry of its own and another SDK may have added one.
231
231
 
232
232
  Use it when the app sends a message itself (a Kafka record, a queue payload, a custom socket). The HTTP instrumentations already inject these headers; do not call it on ordinary HTTP requests. Without an active span it writes only `baggage`, and only when baggage is set.
233
233
 
@@ -252,7 +252,18 @@ end
252
252
 
253
253
  `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.
254
254
 
255
- On outbound requests, Foam writes its propagation headers after any other SDK's. Another SDK's own propagation headers are left as written.
255
+ On outbound requests, Foam writes its propagation headers after any other SDK's. Another SDK's own propagation headers are left as written, and Foam's baggage entries are merged into the members it wrote to `baggage`. These writes go through the propagator in the global slot.
256
+
257
+ ### `propagator`
258
+
259
+ The propagator `inject_trace_context` and `extract_trace_context` use, for an app that sets its own composite in the global slot. List it last, so a caller's `traceparent` wins over another SDK's trace header:
260
+
261
+ ```ruby
262
+ OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator.compose_propagators([
263
+ other_sdk_propagator,
264
+ Foam.propagator
265
+ ])
266
+ ```
256
267
 
257
268
  ### `set_baggage(key, value)`
258
269
 
@@ -485,6 +496,50 @@ Foam.init(
485
496
  )
486
497
  ```
487
498
 
499
+ ### Sentry
500
+
501
+ Sentry sends its events to the DSN's host over HTTP, which Foam would otherwise record as spans. Pass that address and Sentry's request header to `init`. This works whether Sentry sends inline or from its background worker:
502
+
503
+ ```ruby
504
+ dsn = Sentry.configuration.dsn
505
+
506
+ Foam.init(
507
+ name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
508
+ ignored_outbound_hosts: ["#{dsn.host}:#{dsn.port}"],
509
+ ignored_outbound_headers: ["X-Sentry-Auth"]
510
+ )
511
+ ```
512
+
513
+ If the app puts Sentry's propagator in the global slot, through `sentry-opentelemetry`'s `config.otlp.setup_propagator` or `OpenTelemetry.propagation = Sentry::OpenTelemetry::Propagator.new`, outbound requests carry `sentry-trace` but no `traceparent`, so Foam's traces break between services. Once both `init` and that setting have run, wrap Sentry's propagator with Foam's:
514
+
515
+ ```ruby
516
+ OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator.compose_propagators([
517
+ OpenTelemetry.propagation,
518
+ Foam.propagator
519
+ ])
520
+ ```
521
+
522
+ ### New Relic
523
+
524
+ New Relic's agent sends its data to its collector over HTTP, and probes the AWS, Azure and GCP metadata endpoints when it connects, which Foam would otherwise record as spans. Pass the collector's address and the metadata hosts to `init`. The agent's requests carry no header of its own, so there is none to pass:
525
+
526
+ ```ruby
527
+ collector = "#{NewRelic::Agent.config[:host]}:#{NewRelic::Agent.config[:port]}"
528
+
529
+ Foam.init(
530
+ name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
531
+ ignored_outbound_hosts: [
532
+ collector,
533
+ "169.254.169.254", # AWS and Azure metadata
534
+ "metadata.google.internal" # GCP metadata
535
+ ]
536
+ )
537
+ ```
538
+
539
+ Start New Relic before `init`, as Rails does through the agent's Railtie. If the agent instruments Net::HTTP in chain mode and starts after `init`, every Net::HTTP request raises `SystemStackError`. The agent uses chain mode when `instrumentation.net_http` is `chain`, or on its own next to ScoutApm, Rack::MiniProfiler, or Airbrake before 10.0.2. Where the order cannot change, set `instrumentation.net_http` to `prepend`.
540
+
541
+ If the app turns on New Relic's OpenTelemetry bridge (`opentelemetry.enabled`), New Relic takes the tracer provider and propagator, and Foam records no traces. Logs and metrics still reach Foam.
542
+
488
543
  ## Redaction
489
544
 
490
545
  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. When masking a body fails, the whole body is replaced with `********`.
@@ -22,7 +22,7 @@ module Foam
22
22
  }.freeze
23
23
 
24
24
  def self.install
25
- return unless Instrumentations.instance(INSTRUMENTATION).installed?
25
+ return unless Instrumentations.installed?(INSTRUMENTATION)
26
26
 
27
27
  ::OpenTelemetry::Instrumentation::AwsLambda::Wrap.prepend(Wrap)
28
28
  end
@@ -17,7 +17,7 @@ module Foam
17
17
  DECOMPRESSED = %w[gzip x-gzip deflate].freeze
18
18
 
19
19
  def self.install
20
- return unless Instrumentations.instance(INSTRUMENTATION).installed?
20
+ return unless Instrumentations.installed?(INSTRUMENTATION)
21
21
 
22
22
  stack = ::Excon.defaults[:middlewares]
23
23
  return if stack.include?(self)
@@ -28,7 +28,7 @@ module Foam
28
28
  end
29
29
 
30
30
  def self.install
31
- return unless Instrumentations.instance(INSTRUMENTATION).installed?
31
+ return unless Instrumentations.installed?(INSTRUMENTATION)
32
32
 
33
33
  ::Faraday::Connection.prepend(Connection)
34
34
  instrument_default_connection
@@ -13,7 +13,7 @@ module Foam
13
13
  INFLATED_ENCODINGS = %w[gzip x-gzip deflate].freeze
14
14
 
15
15
  def self.install
16
- return unless Instrumentations.instance(INSTRUMENTATION).installed?
16
+ return unless Instrumentations.installed?(INSTRUMENTATION)
17
17
 
18
18
  ::HTTPClient::Session.prepend(self)
19
19
  end
@@ -12,7 +12,7 @@ module Foam
12
12
  attr_reader :current
13
13
 
14
14
  def set(value)
15
- if State.current[:initialized]
15
+ if State.initialized?
16
16
  Diagnostics.warn("set_endpoint after init: exporters keep #{@current}; only later reports use #{value}")
17
17
  end
18
18
  @current = value
@@ -36,7 +36,7 @@ module Foam
36
36
  end
37
37
 
38
38
  # A second configure would install a second pipeline next to the first.
39
- if State.current[:initialized]
39
+ if State.initialized?
40
40
  Report.call(severity: :warn, message: "SDK already initialized", **identity)
41
41
  return
42
42
  end
@@ -90,7 +90,7 @@ module Foam
90
90
  # there while it boots, which would make every app log line raise. LoggerBridge is
91
91
  # prepended after the instrumentation's patch, so it sits in front of it and drops just
92
92
  # the record whose emit failed.
93
- ::Logger.prepend(LoggerBridge) if Instrumentations.instance(Instrumentations::LOGGER).installed?
93
+ ::Logger.prepend(LoggerBridge) if Instrumentations.installed?(Instrumentations::LOGGER)
94
94
 
95
95
  # The client patches set the safe headers on every client span and, in advanced mode,
96
96
  # capture bodies too. Bodies are redacted by the same rules as attributes, so only
@@ -102,6 +102,10 @@ module Foam
102
102
  # Foam's logger provider where it flushes the tracer and meter providers.
103
103
  Safely.call("aws_lambda") { AwsLambda.install }
104
104
 
105
+ # Leaves out a propagation header Que would refuse as a job tag, so the app's enqueue
106
+ # does not raise on a long baggage.
107
+ Safely.call("que") { Que.install }
108
+
105
109
  # A Rails app cannot add the middleware in config.ru the way a plain Rack app does, so
106
110
  # Foam adds it through Rails itself.
107
111
  Safely.call("rails middleware") { RailsMiddleware.install } unless options[:network_capture] == :off
@@ -104,7 +104,7 @@ module Foam
104
104
 
105
105
  # Labels of the instrumentations that installed, in table order, then the caller's entries.
106
106
  def installed(config_map)
107
- config_map.filter_map { |name, _config| label(short_name(name)) if instance(name).installed? }
107
+ config_map.filter_map { |name, _config| label(short_name(name)) if installed?(name) }
108
108
  end
109
109
 
110
110
  # Labels of the instrumentations whose library arrived after init: present, enabled, and
@@ -113,8 +113,10 @@ module Foam
113
113
  config_map.filter_map { |name, config| label(short_name(name)) if late?(instance(name), config) }
114
114
  end
115
115
 
116
+ # An entry another SDK deleted from the registry after init (see installed?) has no instance to
117
+ # ask, so it counts as neither installed nor late.
116
118
  def late?(instance, config)
117
- !instance.installed? && instance.present? && instance.enabled?(config) && compatible?(instance)
119
+ !instance.nil? && !instance.installed? && instance.present? && instance.enabled?(config) && compatible?(instance)
118
120
  end
119
121
 
120
122
  # A compatibility block reads the library's version constant, which can raise on an odd install.
@@ -128,6 +130,12 @@ module Foam
128
130
  OpenTelemetry::Instrumentation.registry.lookup(name)
129
131
  end
130
132
 
133
+ # Another SDK can remove entries from the registry, as New Relic's OpenTelemetry bridge does
134
+ # for the libraries it covers itself, so a missing entry reads as not installed.
135
+ def installed?(name)
136
+ instance(name)&.installed? || false
137
+ end
138
+
131
139
  # The table's short name, or one derived the same way for an instrumentation outside the bundle.
132
140
  def short_name(name)
133
141
  TABLE.fetch(name) do
@@ -4,10 +4,86 @@ module Foam
4
4
  module Otel
5
5
  module Internal
6
6
  module Propagation
7
+ # Writes W3C trace context, as OpenTelemetry's propagator does, but does not replace a
8
+ # tracestate header that is already on the outgoing request. Foam has no tracestate entry of
9
+ # its own. Another SDK, such as New Relic or Datadog, can write tracestate first and put its
10
+ # entry (@nr, dd) before the caller's entries. If Foam wrote tracestate again, that entry
11
+ # would be lost. Foam always writes traceparent.
12
+ module KeptTracestate
13
+ UPSTREAM = OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator
14
+
15
+ # Passes every header through but tracestate.
16
+ SkipTracestate = Struct.new(:setter) do
17
+ def set(carrier, key, value)
18
+ setter.set(carrier, key, value) unless key == "tracestate"
19
+ end
20
+ end
21
+
22
+ module_function
23
+
24
+ def inject(carrier, context:, setter:)
25
+ setter = SkipTracestate.new(setter) if header(carrier)
26
+ UPSTREAM.inject(carrier, context: context, setter: setter)
27
+ end
28
+
29
+ def extract(carrier, **) = UPSTREAM.extract(carrier, **)
30
+
31
+ def fields = UPSTREAM.fields
32
+
33
+ # Reads the carrier's tracestate header as one string, or nil when it has none. Carriers
34
+ # differ: HTTPClient returns an array of header values, the others a string. A list carrier,
35
+ # such as Que's job tags, cannot be read by header name, so there is nothing to keep.
36
+ def header(carrier)
37
+ return if carrier.is_a?(Array)
38
+
39
+ value = Array(carrier["tracestate"]).join(",")
40
+ value unless value.empty?
41
+ end
42
+ end
43
+
44
+ # The W3C baggage header is shared between SDKs: Sentry, for one, writes its sampling context
45
+ # there as sentry-* members. OpenTelemetry's baggage propagator overwrites the whole header
46
+ # with Foam's entries, so whichever SDK wrote first loses its members. This wraps it to merge
47
+ # instead, keeping the other SDK's members and letting Foam's value win a shared key.
48
+ module MergingBaggage
49
+ UPSTREAM = OpenTelemetry::Baggage::Propagation.text_map_propagator
50
+
51
+ module_function
52
+
53
+ def inject(carrier, context:, setter:)
54
+ theirs = header(carrier)
55
+ UPSTREAM.inject(carrier, context: context, setter: setter)
56
+ ours = header(carrier)
57
+ return if theirs.nil? || ours == theirs
58
+
59
+ keys = ours.split(",").map { |member| key(member) }
60
+ kept = theirs.split(",").map(&:strip).reject { |member| member.empty? || keys.include?(key(member)) }
61
+ setter.set(carrier, "baggage", [ours, *kept].join(","))
62
+ end
63
+
64
+ def extract(carrier, **) = UPSTREAM.extract(carrier, **)
65
+
66
+ def fields = UPSTREAM.fields
67
+
68
+ # Reads the carrier's baggage header as one string, or nil when it has none. inject reads it
69
+ # before and after the upstream write, to tell the other SDK's members from Foam's. Carriers
70
+ # differ: HTTPClient returns an array of header values, the others a string. A list carrier,
71
+ # such as Que's job tags, is written through its setter and cannot be read by header name,
72
+ # so there is nothing to merge.
73
+ def header(carrier)
74
+ return if carrier.is_a?(Array)
75
+
76
+ value = Array(carrier["baggage"]).join(",")
77
+ value unless value.empty?
78
+ end
79
+
80
+ def key(member) = member.split("=", 2).first.strip
81
+ end
82
+
7
83
  # Foam's own W3C propagator, so the helpers work the same whoever configured the global one.
8
84
  PROPAGATOR = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator.compose_propagators([
9
- OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator,
10
- OpenTelemetry::Baggage::Propagation.text_map_propagator
85
+ KeptTracestate,
86
+ MergingBaggage
11
87
  ])
12
88
 
13
89
  module_function
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Foam's addition to the que instrumentation, prepended on its TagSetter by init after
7
+ # installation. The instrumentation writes each propagation header into the job's tags as
8
+ # "key:value", and Que refuses to enqueue a job with a tag longer than MAXIMUM_TAG_LENGTH or
9
+ # more than MAXIMUM_TAGS_COUNT tags, so a long baggage, the app's or another SDK's, would make
10
+ # the app's enqueue raise. The setter leaves out a header Que would refuse, warning once, and
11
+ # the job enqueues without it.
12
+ module Que
13
+ INSTRUMENTATION = "OpenTelemetry::Instrumentation::Que"
14
+
15
+ def self.install
16
+ return unless Instrumentations.installed?(INSTRUMENTATION)
17
+
18
+ ::OpenTelemetry::Instrumentation::Que::TagSetter.singleton_class.prepend(TagSetter)
19
+ end
20
+
21
+ def self.fits?(tags, tag)
22
+ tag.length <= ::Que::Job::MAXIMUM_TAG_LENGTH && tags.length < ::Que::Job::MAXIMUM_TAGS_COUNT
23
+ end
24
+
25
+ def self.warn_left_out(key)
26
+ return if @warned
27
+
28
+ @warned = true
29
+ Diagnostics.warn(
30
+ "propagation header #{key} left out of a Que job's tags; Que allows at most " \
31
+ "#{::Que::Job::MAXIMUM_TAGS_COUNT} tags of #{::Que::Job::MAXIMUM_TAG_LENGTH} characters"
32
+ )
33
+ end
34
+
35
+ module TagSetter
36
+ def set(carrier, key, value)
37
+ return super if Que.fits?(carrier, "#{key}:#{value}")
38
+
39
+ Que.warn_left_out(key)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -46,7 +46,7 @@ module Foam
46
46
  # it builds the real stack late in initialize!. Anchored on the OpenTelemetry class, not
47
47
  # position 0, so another SDK inserting at the top cannot come between the two.
48
48
  def insert(app)
49
- return unless Instrumentations.instance(ACTION_PACK).installed?
49
+ return unless Instrumentations.installed?(ACTION_PACK)
50
50
 
51
51
  otel = Instrumentations.instance(RACK).middleware_args.first
52
52
  app.middleware.insert_after(otel, Foam::Rack::Middleware)
@@ -11,8 +11,9 @@ module Foam
11
11
  module Reinject
12
12
  module_function
13
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.
14
+ # The global propagator, the one the instrumentation used: whoever holds the slot, before or
15
+ # after init, decides the headers. Without a span there is nothing the instrumentation
16
+ # wrote, so nothing is written here either.
16
17
  def call(carrier, context: OpenTelemetry::Context.current)
17
18
  return if OwnRequest.current? || !OpenTelemetry::Trace.current_span(context).context.valid?
18
19
 
@@ -108,7 +109,7 @@ module Foam
108
109
 
109
110
  def install
110
111
  HOOKS.each do |hook, (klass, instrumentation)|
111
- next unless instrumentation.nil? || Instrumentations.instance(instrumentation).installed?
112
+ next unless instrumentation.nil? || Instrumentations.installed?(instrumentation)
112
113
 
113
114
  Object.const_get(klass).prepend(hook)
114
115
  end
@@ -35,6 +35,12 @@ module Foam
35
35
  end
36
36
  end
37
37
 
38
+ # Whether init has completed, without reading the live slots as current does, which warns
39
+ # about a slot another SDK took since.
40
+ def initialized?
41
+ @mutex.synchronize { @initialized }
42
+ end
43
+
38
44
  # Gate for the helpers: the hash is replaced whole, never mutated, so no lock.
39
45
  def active?(signal)
40
46
  @signals[signal] != :none
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foam
4
4
  module Otel
5
- VERSION = "3.0.0.alpha.9"
5
+ VERSION = "3.0.0.alpha.11"
6
6
  end
7
7
  end
data/lib/foam/otel.rb CHANGED
@@ -23,6 +23,7 @@ require_relative "otel/internal/clients/excon"
23
23
  require_relative "otel/internal/clients/http_client"
24
24
  require_relative "otel/internal/clients/faraday"
25
25
  require_relative "otel/internal/aws_lambda"
26
+ require_relative "otel/internal/que"
26
27
  require_relative "otel/internal/rails_middleware"
27
28
  require_relative "otel/internal/before_send"
28
29
  require_relative "otel/internal/instrumentations"
@@ -129,6 +130,11 @@ module Foam
129
130
  end
130
131
  end
131
132
 
133
+ # The propagator the two helpers above use, for an app that composes its own.
134
+ def propagator
135
+ Internal::Propagation::PROPAGATOR
136
+ end
137
+
132
138
  def set_baggage(key, value)
133
139
  Internal::Safely.call("set_baggage") do
134
140
  Internal::Propagation.set_baggage(key, value)
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.9
4
+ version: 3.0.0.alpha.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-exporter-otlp
@@ -204,6 +204,7 @@ files:
204
204
  - lib/foam/otel/internal/own_request.rb
205
205
  - lib/foam/otel/internal/patch_resolver.rb
206
206
  - lib/foam/otel/internal/propagation.rb
207
+ - lib/foam/otel/internal/que.rb
207
208
  - lib/foam/otel/internal/rack/body_tee.rb
208
209
  - lib/foam/otel/internal/rack/input_tee.rb
209
210
  - lib/foam/otel/internal/rails_middleware.rb