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

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: 929a47fe32e662ab012e7995078f1b0bab1f0ef3b58e3b7a9ca8b3b109c6ca29
4
- data.tar.gz: 1608559fa17bd0f683492267a2db2f7daa4f3989d4a79f06d67f38e27632174d
3
+ metadata.gz: 240906547c665dd40513921db5ab9f00d65febef81d40848faa91a1f5ae02e15
4
+ data.tar.gz: 9a2c4c5045ba080c21b165e1a87836cd9288408343b049c293352162e9be7083
5
5
  SHA512:
6
- metadata.gz: b8484befaf21fec24e8ce725931c368d9aaac7518916a8f0aebc69f02a5345fc10a0a73c6b25167d555554133eb84b7f6479d3ad676697beb2c03758352dd592
7
- data.tar.gz: 56032f62347e799791dce8d37db046dd6fe6e4c8e372dd4aff7fa7c54813992b89e61b854b9b6565aeaf9fd7dd08c53747d5c9b8eae897b96ae869622594987e
6
+ metadata.gz: f891d7d8a749ce8a9042f08183581ec43924445d21137b863619a912cd0be0bb516e340d01e2ed6e238356a3b61f3cedb5739b769b04e38213d785712616cb9f
7
+ data.tar.gz: dd19f649f254cff7a00da71b14f158cc3d3d370a3affcc587ec86156d5439aaecd956564e106c54f0c0b2d229195065f69c03ab3eae9058459265ea479b71aaf
data/README.md CHANGED
@@ -92,7 +92,7 @@ TODO(jhartquist): Gen AI providers beyond `anthropic`. There is no OpenTelemetry
92
92
 
93
93
  ## Public API
94
94
 
95
- Every method lives on the `Foam` module. Other than the ingest factories and `init`'s keyword errors, none of them raise exceptions. `set_baggage`, the metric helpers, and `log` need a Foam export path to function properly, so they are no-ops before `init`; the trace context helpers and `record_exception` work on the current OpenTelemetry context whether or not `init` ran.
95
+ Every method lives on the `Foam` module. A missing or invalid value is reported in Foam's log output instead of raising; only an unknown keyword raises `ArgumentError`. `set_baggage`, the metric helpers, and `log` need a Foam export path to function properly, so they are no-ops before `init`; the trace context helpers and `record_exception` work on the current OpenTelemetry context whether or not `init` ran.
96
96
 
97
97
  ### `init`
98
98
 
@@ -221,7 +221,7 @@ All three export to the endpoint set by `set_endpoint` and take the same `redact
221
221
 
222
222
  Foam identifies the service by the `name` and `environment` passed in: on the copy sent to Foam they overwrite the provider's `service.name` and `deployment.environment.name`, and `foam.ingest.host` names the host of the endpoint the copy goes to. The provider's own telemetry is left unchanged. Pass the same values to all three.
223
223
 
224
- Unlike every other public method, these are not guarded: they raise `ArgumentError` when `name`, `environment`, or `token` is blank or `redact` or `before_send` is invalid, and let any other failure through.
224
+ A missing or blank `name`, `environment`, or `token`, or an invalid `redact` or `before_send` value, is reported in Foam's log output and turns off just that helper: it returns a component that exports nothing, which the app adds to its provider as usual, and the signal stays `none` in `state`, so an unset environment variable never stops the app from booting.
225
225
 
226
226
  Do not combine `init` and an ingest component for the same signal. The ingest components do not install instrumentations; register the ones the app needs with the OpenTelemetry SDK directly.
227
227
 
@@ -280,7 +280,7 @@ Foam.set_metric("cache.size", 1024, unit: "By", description: "Entries in the cac
280
280
  - `add_up_down_counter(name, value, ...)` adds to a counter that can go down; pass a negative value to decrement.
281
281
  - `set_metric(name, value, ...)` sets a gauge to its latest value.
282
282
 
283
- Every helper takes `attributes: {}`, `unit: nil`, and `description: nil`. The instrument is created on the first call for a name, and `unit` and `description` apply then; later calls reuse it. Calls before `init`, when Foam is disabled, or in a setup that only attaches an ingest metric reader are dropped.
283
+ Every helper takes `attributes: {}`, `unit: nil`, and `description: nil`. The instrument is created on the first call for a name, and `unit` and `description` apply then; later calls reuse it. Without `init`, when `create_foam_ingest_metric_reader` is attached to the app's own meter provider, the helpers record on the global meter provider, so their values reach Foam through that reader. Calls before `init` with no ingest metric reader, or when Foam is disabled, are dropped.
284
284
 
285
285
  TODO(jhartquist): observable (pull) instruments. The metrics SDK has observable counters, gauges, and up-down counters; the helpers cover the four synchronous kinds only. v1 used observables for runtime metrics (GC, heap, threads, RSS), which this version does not send.
286
286
 
@@ -314,7 +314,7 @@ The span gets an `exception` event with the type, message, and stack trace, plus
314
314
 
315
315
  ### `flush(timeout: 10)`
316
316
 
317
- Exports everything Foam's providers still hold. Call it before a point where the process may be killed without a normal exit, for example at the end of a job on a runner that stops the process.
317
+ Exports everything Foam's providers and ingest components still hold. Call it before a point where the process may be killed without a normal exit, for example at the end of a job on a runner that stops the process.
318
318
 
319
319
  ```ruby
320
320
  Foam.flush(timeout: 5)
@@ -324,7 +324,7 @@ Foam.flush(timeout: 5)
324
324
 
325
325
  ### `shutdown(timeout: 10)`
326
326
 
327
- Flushes and shuts down Foam's providers, including the one behind `log` when an ingest component created it, and releases the global slots Foam claimed. Both `init` and the ingest log factory register it with `at_exit`, so most apps never call it. An app calls it before a process is killed without a normal exit.
327
+ Flushes and shuts down Foam's providers, including the one behind `log` when an ingest component created it, and the ingest components, and releases the global slots Foam claimed. `init` and each ingest factory register it with `at_exit`, so most apps never call it, and an app that never shuts its own providers down still delivers the ingest components' last batch. An app calls it before a process is killed without a normal exit.
328
328
 
329
329
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
330
330
 
@@ -367,7 +367,7 @@ Each `signals` value names the source of the Foam export path for that signal:
367
367
  - `:local` - logs only: another SDK owns the global provider and Foam keeps its own so `log` still delivers.
368
368
  - `:none` - no Foam export path.
369
369
 
370
- A slot another SDK held before `init` stays with it and reports `:none` (`:local` for logs). Foam builds no provider for that signal, so the metric helpers and `set_baggage` do nothing while `log` still delivers. A slot another SDK takes after `init` reports the same way, but Foam's helpers keep exporting through Foam's own providers. Both cases are warned about once on stderr. Body capture is off while another SDK holds the logs slot. `baggage` is `:global` or `:none`. `profile` is not supported and always reports `:none`.
370
+ A slot another SDK held before `init` stays with it and reports `:none` (`:local` for logs), or `:ingest` when a `create_foam_ingest_*` object was already built for that signal. Foam builds no provider for that signal, so the metric helpers and `set_baggage` do nothing while `log` still delivers. A slot another SDK takes after `init` reports the same way, but Foam's helpers keep exporting through Foam's own providers. Both cases are warned about once on stderr. Body capture is off while another SDK holds the logs slot. `baggage` is `:global` or `:none`. `profile` is not supported and always reports `:none`.
371
371
 
372
372
  ## Custom spans and other OpenTelemetry APIs
373
373
 
@@ -464,6 +464,27 @@ Foam.init(
464
464
  )
465
465
  ```
466
466
 
467
+ If the app loads Datadog's OpenTelemetry support (`require "datadog/opentelemetry"`) and configures the OpenTelemetry SDK itself, `OpenTelemetry::SDK.configure` installs Datadog's span processor on the app's tracer provider, next to the ingest helpers. Two Datadog bugs then affect what Foam receives:
468
+
469
+ - Once a child span ends, spans started afterwards under the same parent begin new traces. Foam receives the trace split at that point, and logs written afterwards are not correlated to the parent. Datadog's own copy of the trace is complete.
470
+ - `force_flush` and `shutdown` on the app's tracer provider raise inside Datadog's span processor, and the processors after it, the ingest span processor included, are skipped. The app does not need either call for Foam: Foam flushes the ingest components at exit.
471
+
472
+ Where the app does not need to own the OpenTelemetry SDK, call `init` instead and do not call `OpenTelemetry::SDK.configure`. Datadog's span processor is then not installed, so neither bug applies. Spans made through the OpenTelemetry API go to Foam only, and Datadog keeps what its own integrations record.
473
+
474
+ ### Rollbar
475
+
476
+ Rollbar sends each report to its API over HTTP, which Foam would otherwise record as spans. Pass Rollbar's endpoint and request header to `init`. This works whether Rollbar reports inline, from a background thread, or from a Sidekiq job:
477
+
478
+ ```ruby
479
+ rollbar = URI(Rollbar.configuration.endpoint)
480
+
481
+ Foam.init(
482
+ name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
483
+ ignored_outbound_hosts: ["#{rollbar.host}:#{rollbar.port}"],
484
+ ignored_outbound_headers: ["X-Rollbar-Access-Token"]
485
+ )
486
+ ```
487
+
467
488
  ## Redaction
468
489
 
469
490
  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 `********`.
@@ -576,7 +597,7 @@ OpenTelemetry packages outside the instrumentation bundle that Foam does not wir
576
597
 
577
598
  ## TODO(jhartquist): Safely
578
599
 
579
- Every public method except the ingest factories, and every capture hook, runs under `Internal::Safely`: a failure inside the gem is written to stderr with the `[foam-otel]` prefix and the call returns a fallback, so nothing reaches the app. One broad boundary per public call and per hook, instead of a wrap at each site, would give one place to rate-limit and report, at the cost of skipping the rest of a hook after its first failure. Additionally, a failure could also be reported to Foam over the path `init` uses for its own outcome.
600
+ Every public method, and every capture hook, runs under `Internal::Safely`: a failure inside the gem is written to stderr with the `[foam-otel]` prefix and the call returns a fallback, so nothing reaches the app. One broad boundary per public call and per hook, instead of a wrap at each site, would give one place to rate-limit and report, at the cost of skipping the rest of a hook after its first failure. Additionally, a failure could also be reported to Foam over the path `init` uses for its own outcome.
580
601
 
581
602
  ## Development
582
603
 
@@ -12,34 +12,38 @@ module Foam
12
12
  "before_send must respond to call, got #{hook.inspect}"
13
13
  end
14
14
 
15
- # Takes the before_send option as init or an ingest factory received it; nil means no hook.
15
+ # Takes the before_send option as init or an ingest factory received it, after checking it
16
+ # with invalid_option; nil means no hook.
16
17
  def initialize(hook)
17
- problem = BeforeSend.invalid_option(hook)
18
- raise ArgumentError, problem if problem
19
-
20
18
  @hook = hook
21
19
  freeze
22
20
  end
23
21
 
24
22
  # Called by the exporters with each hook event they built; returns what to send, or nil to drop it.
25
23
  # A hook that raises, returns something else, or gets an event that cannot be copied changes
26
- # nothing, so a broken hook never loses telemetry.
24
+ # nothing, so a broken hook never loses telemetry. The block receives what the hook raised, so
25
+ # the exporter can say so once for its batch.
27
26
  def event_to_export(hook_event)
28
27
  return hook_event unless @hook
29
28
 
30
- result = @hook.call(deep_copy(hook_event))
29
+ copy = deep_copy(hook_event) or return hook_event
30
+ result = @hook.call(copy)
31
31
  return if result.nil?
32
32
 
33
33
  hook_event?(result, hook_event[:type]) ? result : hook_event
34
- rescue
34
+ rescue => error
35
+ yield error
35
36
  hook_event
36
37
  end
37
38
 
38
39
  private
39
40
 
40
- # Marshal thaws the SDK's frozen attributes, so the hook can edit in place.
41
+ # Marshal thaws the SDK's frozen attributes, so the hook can edit in place. nil for an event
42
+ # holding something Marshal cannot dump, such as an IO.
41
43
  def deep_copy(hook_event)
42
44
  Marshal.load(Marshal.dump(hook_event))
45
+ rescue
46
+ nil
43
47
  end
44
48
 
45
49
  def hook_event?(result, type)
@@ -20,9 +20,15 @@ module Foam
20
20
  CHUNK_INDEX = "foam.http.body.chunk.index"
21
21
  CHUNK_BYTES = "foam.http.body.chunk.bytes"
22
22
 
23
- # The context key a capturing middleware marks its span under, so the transport patches
24
- # beneath it leave that span alone.
25
- OWNED_SPAN = OpenTelemetry::Context.create_key("foam-otel.owned-span")
23
+ # Where a middleware that captures above its transport, such as Faraday, puts its Owner in
24
+ # the context. The transport patches beneath it then skip its span and report to it what
25
+ # they inflated.
26
+ OWNER = OpenTelemetry::Context.create_key("foam-otel.owner")
27
+
28
+ # The span a middleware captures on, above its transport. When the transport inflates the
29
+ # response, it sets encoding and wire_bytes to the response's encoding and size on the wire;
30
+ # otherwise both stay nil.
31
+ Owner = Struct.new(:span, :encoding, :wire_bytes)
26
32
 
27
33
  # The span attributes of each direction. Size and the header prefix are the semantic
28
34
  # conventions' names; the rest are Foam's.
@@ -96,10 +102,19 @@ module Foam
96
102
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
103
  end
98
104
 
99
- # Runs the block with span owned by the caller, so the transport patches inside it
100
- # capture nothing on that span and a library capturing above its transport captures once.
101
- def owning(span, &block)
102
- OpenTelemetry::Context.with_value(OWNED_SPAN, span, &block)
105
+ # Runs the block under the owner, so the transport patches inside it capture nothing on
106
+ # its span and each request is captured once.
107
+ def owning(owner, &block)
108
+ OpenTelemetry::Context.with_value(OWNER, owner, &block)
109
+ end
110
+
111
+ # Called by a transport patch that inflated a response: records the response's encoding
112
+ # and size on the wire on the middleware that owns the request. Does nothing when no
113
+ # middleware owns it.
114
+ def inflated_below(encoding, wire_bytes)
115
+ owner = OpenTelemetry::Context.current.value(OWNER)
116
+ owner&.encoding = encoding
117
+ owner&.wire_bytes = wire_bytes
103
118
  end
104
119
 
105
120
  # Returns the recording client span an outbound request belongs to, or nil when nothing is
@@ -114,7 +129,7 @@ module Foam
114
129
  span = OpenTelemetry::Trace.current_span
115
130
  return unless span.recording? && span.kind == :client
116
131
 
117
- span unless OpenTelemetry::Context.current.value(OWNED_SPAN).equal?(span)
132
+ span unless OpenTelemetry::Context.current.value(OWNER)&.span.equal?(span)
118
133
  end
119
134
 
120
135
  # Sets the safe headers of one direction on the span, in the semantic conventions' dash
@@ -140,6 +155,7 @@ module Foam
140
155
  @chunks = []
141
156
  @observed_bytes = 0
142
157
  @kept_bytes = 0
158
+ @wire_bytes = nil
143
159
  @truncated = false
144
160
  @open = true
145
161
  @lock = Mutex.new
@@ -183,6 +199,13 @@ module Foam
183
199
  end
184
200
  end
185
201
 
202
+ # Tells the capture that the client library already inflated the observed bytes, and that
203
+ # wire_bytes is the body's size on the wire. The bytes are then read as text without
204
+ # decoding, and the size attributes report wire_bytes.
205
+ def inflated(wire_bytes)
206
+ @wire_bytes = wire_bytes
207
+ end
208
+
186
209
  # Ends the capture and emits its span attributes and records. Runs once; later calls do
187
210
  # nothing. The records are emitted even when the span has already ended.
188
211
  def finalize(complete:)
@@ -217,11 +240,12 @@ module Foam
217
240
  text = @body_redactor.redact(text, headers["content-type"]) if text
218
241
  truncated = @truncated || cut
219
242
  outcome = outcome_of(complete)
243
+ size = @wire_bytes || @observed_bytes
220
244
 
221
245
  if @span.recording?
222
246
  @span.set_attribute(@attributes[:complete], complete)
223
247
  @span.set_attribute(@attributes[:truncated], truncated)
224
- @span.set_attribute(@attributes[:size], @observed_bytes)
248
+ @span.set_attribute(@attributes[:size], size)
225
249
  end
226
250
  return if outcome == "empty"
227
251
 
@@ -233,8 +257,8 @@ module Foam
233
257
  COMPLETE => complete,
234
258
  TRUNCATED => truncated,
235
259
  OUTCOME => outcome,
236
- @attributes[:size] => @observed_bytes,
237
- CAPTURED_BYTES => text ? text.bytesize : @kept_bytes,
260
+ @attributes[:size] => size,
261
+ CAPTURED_BYTES => text ? text.bytesize : 0,
238
262
  CHUNK_COUNT => chunks.size
239
263
  }.merge(header_attributes(headers))
240
264
 
@@ -257,8 +281,8 @@ module Foam
257
281
  # body cannot be read as text: an unknown or corrupt encoding, one inflating past the
258
282
  # bound, or a binary content type. Only the metadata is exported then, never the bytes.
259
283
  def decoded_text(headers)
260
- wire = (@chunks.size == 1) ? @chunks.first : @chunks.join
261
- decoded = BodyDecoder.decode(wire, headers["content-encoding"])
284
+ kept = (@chunks.size == 1) ? @chunks.first : @chunks.join
285
+ decoded = @wire_bytes ? kept : BodyDecoder.decode(kept, headers["content-encoding"])
262
286
  return [nil, false] unless decoded
263
287
 
264
288
  BodyDecoder.text(decoded, ContentType.parse(headers["content-type"])) || [nil, false]
@@ -11,6 +11,14 @@ module Foam
11
11
  module BodyDecoder
12
12
  INFLATED_ENCODINGS = %w[gzip x-gzip deflate].freeze
13
13
 
14
+ # How much of a body of unknown type is looked at to tell whether it is text.
15
+ SNIFF_BYTES = 1024
16
+ # Control characters other than tab, line feed, form feed, and carriage return.
17
+ CONTROL = /[\x00-\x08\x0B\x0E-\x1F\x7F]/
18
+ # The start of an archive, image, executable, or PDF, long enough that no text starts that
19
+ # way. A Windows executable is left to the NUL check, since its two-letter MZ is ordinary text.
20
+ FILE_SIGNATURE = /\A(?:PK\x03\x04|\x1F\x8B|%PDF-|\x89PNG|\xFF\xD8\xFF|GIF8[79]a|Rar!\x1A\x07|7z\xBC\xAF|BZh[1-9]1AY&SY|\x7FELF)/n
21
+
14
22
  module_function
15
23
 
16
24
  # Undoes the content encoding and returns the decoded bytes. The header lists encodings in
@@ -26,9 +34,12 @@ module Foam
26
34
 
27
35
  # Returns the body as UTF-8 text and whether it was cut at the capture limit. Invalid
28
36
  # bytes are replaced and the cut lands on a character boundary. Returns nil when the
29
- # content type is not text.
37
+ # content type is binary, or unknown or missing and the bytes do not read as text, or the
38
+ # bytes are a file whatever the type says. A multipart body's parts are judged on their own.
30
39
  def text(bytes, content_type)
31
- return unless content_type.text?
40
+ readable = content_type.text? || (!content_type.binary? && textual?(bytes))
41
+ readable &&= content_type.multipart? || !file?(bytes)
42
+ return unless readable
32
43
 
33
44
  cut = bytes.bytesize > Constants::BODY_CAPTURE_BYTES
34
45
  text = cut ? bytes.byteslice(0, Constants::BODY_CAPTURE_BYTES) : bytes.dup
@@ -42,6 +53,20 @@ module Foam
42
53
  [text, cut]
43
54
  end
44
55
 
56
+ # Whether the first KiB holds a NUL or starts with a file signature.
57
+ def file?(bytes)
58
+ start = bytes.byteslice(0, SNIFF_BYTES).b
59
+ start.include?("\x00") || FILE_SIGNATURE.match?(start)
60
+ end
61
+
62
+ # Whether the start of the bytes is UTF-8 without control characters, allowing a
63
+ # character cut off where the look ends before the bytes do.
64
+ def textual?(bytes)
65
+ start = bytes.byteslice(0, SNIFF_BYTES).force_encoding(Encoding::UTF_8)
66
+ start = whole_utf8(start) if bytes.bytesize > SNIFF_BYTES
67
+ start.valid_encoding? && !CONTROL.match?(start)
68
+ end
69
+
45
70
  # Splits the text into pieces of at most the chunk size that end on character boundaries.
46
71
  def chunks(text)
47
72
  pieces = []
@@ -30,8 +30,12 @@ module Foam
30
30
  HTML_INPUT = /<input\b[^>]*>/i
31
31
  HTML_INPUT_NAME = /\bname\s*=\s*(["'])(.*?)\1/i
32
32
  HTML_INPUT_VALUE = /(\bvalue\s*=\s*)(["'])(.*?)\2/i
33
+ HTML_PASSWORD_TYPE = /\btype\s*=\s*(["']?)password\1(?=[\s>\/])/i
33
34
  YAML_PAIR = /\A(\s*(?:-\s*)?)(["']?)([A-Za-z_][\w.-]*)\2(\s*:\s*)(.*)\z/
34
35
  YAML_BLOCK_SCALAR = /\A[>|]/
36
+ YAML_ANCHOR = /\A&[^\s,\[\]{}]+\s*/
37
+ # An alias token: an asterisk after the line start, whitespace, or a flow indicator.
38
+ YAML_ALIAS = /(?<![^\s\[{,])\*([^\s,\[\]{}]+)/
35
39
  MULTIPART_HEADER_SEPARATOR = /\r\n\r\n|\n\n|\r\r/
36
40
  CONTENT_DISPOSITION = /\Acontent-disposition\s*:/i
37
41
  CONTENT_DISPOSITION_NAME = /\bname\s*=\s*(?:"([^"]*)"|([^;\s]*))/i
@@ -44,8 +48,9 @@ module Foam
44
48
  freeze
45
49
  end
46
50
 
47
- # Masks the values under sensitive keys as the content type is read; a type it cannot read
48
- # is left alone, and a failure masks the whole body rather than leak it.
51
+ # Masks the values under sensitive keys as the content type is read; a body of unknown or
52
+ # missing type is read as plain text, a binary type is left alone, and a failure masks the
53
+ # whole body rather than leak it.
49
54
  def redact(text, content_type)
50
55
  type = ContentType.parse(content_type)
51
56
  redacted =
@@ -65,7 +70,7 @@ module Foam
65
70
  redact_xml(text)
66
71
  elsif type.yaml?
67
72
  redact_yaml(text)
68
- elsif type.text?
73
+ elsif !type.binary?
69
74
  redact_plain_text(text)
70
75
  end
71
76
  redacted || text
@@ -242,8 +247,7 @@ module Foam
242
247
  end
243
248
 
244
249
  output = output.gsub(HTML_INPUT) do |tag|
245
- name = HTML_INPUT_NAME.match(tag)&.[](2)
246
- next tag unless name && sensitive_name?(name)
250
+ next tag unless sensitive_input?(tag)
247
251
 
248
252
  replaced = tag.sub(HTML_INPUT_VALUE, "\\1\\2#{Constants::REDACTED_VALUE}\\2")
249
253
  changed = true unless replaced == tag
@@ -253,6 +257,14 @@ module Foam
253
257
  output if changed
254
258
  end
255
259
 
260
+ # An input of type password, or one named for a sensitive key or a query key.
261
+ def sensitive_input?(tag)
262
+ return true if HTML_PASSWORD_TYPE.match?(tag)
263
+
264
+ name = HTML_INPUT_NAME.match(tag)&.[](2)
265
+ name ? sensitive_name?(name) : false
266
+ end
267
+
256
268
  # The text of each sensitive element is masked; one holding markup or never closed gets the
257
269
  # plain mask, since its value cannot be read whole. Positions are bytes throughout, so the
258
270
  # body's length costs nothing.
@@ -304,26 +316,39 @@ module Foam
304
316
  colon ? name[(colon + 1)..] : name
305
317
  end
306
318
 
307
- # A sensitive key's value is masked on its line; a block scalar under one spans lines that
308
- # cannot be told apart from the rest, so the whole document is masked instead.
319
+ # A sensitive key's value is masked on its line, keeping an anchor on it, and every later
320
+ # alias of that anchor gets the same mask. A block scalar under a sensitive key spans lines
321
+ # that cannot be told apart from the rest, so the whole document is masked instead.
309
322
  def redact_yaml(text)
310
323
  block_scalar = false
324
+ masked_anchors = {}
311
325
  redacted = redact_by_line(text) do |line|
312
326
  match = YAML_PAIR.match(line)
313
- next unless match
327
+ kind = match && @redactor.mask_kind(match[3])
328
+ next mask_yaml_aliases(line, masked_anchors) unless kind
314
329
 
315
330
  prefix, quote, key, separator, value = match.captures
316
- kind = @redactor.mask_kind(key)
317
- next unless kind
318
-
319
331
  value = value.strip
320
332
  block_scalar = true if YAML_BLOCK_SCALAR.match?(value)
333
+ anchor = value[YAML_ANCHOR].to_s
334
+ value = value.delete_prefix(anchor)
321
335
  value = value[1..-2] if value.match?(/\A(["']).*\1\z/)
322
- "#{prefix}#{quote}#{key}#{quote}#{separator}#{@redactor.mask(kind, value)}"
336
+ masked = @redactor.mask(kind, value)
337
+ masked_anchors[anchor.strip.delete_prefix("&")] = masked unless anchor.empty?
338
+ "#{prefix}#{quote}#{key}#{quote}#{separator}#{anchor}#{masked}"
323
339
  end
324
340
  block_scalar ? Constants::REDACTED_VALUE : redacted
325
341
  end
326
342
 
343
+ # The line with each alias of a masked anchor replaced by that anchor's mask; nil when it
344
+ # has none.
345
+ def mask_yaml_aliases(line, masked_anchors)
346
+ return if masked_anchors.empty?
347
+
348
+ replaced = line.gsub(YAML_ALIAS) { |token| masked_anchors.fetch(Regexp.last_match(1), token) }
349
+ replaced unless replaced == line
350
+ end
351
+
327
352
  # Text with no declared structure is read as JSON when it looks like it, then as YAML, then
328
353
  # scanned for key-value pairs.
329
354
  def redact_plain_text(text)
@@ -336,7 +361,7 @@ module Foam
336
361
  end
337
362
 
338
363
  # Each part is redacted on its own: a part named for a sensitive key is masked whole, a file
339
- # part is left alone, and any other part is read by its own content type.
364
+ # part is omitted, and any other part is read by its own content type.
340
365
  def redact_multipart(text, boundary)
341
366
  return if boundary.empty? || boundary.length > MAX_BOUNDARY_LENGTH
342
367
 
@@ -369,12 +394,23 @@ module Foam
369
394
  def redact_part(headers, body)
370
395
  name = content_disposition_name(headers)
371
396
  return Constants::REDACTED_VALUE if name && sensitive_name?(name)
372
- return if FILENAME.match?(headers)
373
397
 
374
- redacted = redact(body, part_content_type(headers) || "text/plain")
398
+ type = part_content_type(headers)
399
+ return omit(body) if FILENAME.match?(headers) || ContentType.parse(type).binary? || file_part?(body)
400
+
401
+ redacted = redact(body, type || "text/plain")
375
402
  redacted unless redacted == body
376
403
  end
377
404
 
405
+ def omit(body)
406
+ Constants::OMITTED_VALUE unless body == Constants::OMITTED_VALUE
407
+ end
408
+
409
+ # Multipart text holds each byte as one character, so the part is turned back into its bytes.
410
+ def file_part?(body)
411
+ BodyDecoder.file?(body.encode(Encoding::ISO_8859_1, invalid: :replace, undef: :replace))
412
+ end
413
+
378
414
  # A form field or part name is matched against the key lists and the query key list alike.
379
415
  def sensitive_name?(name)
380
416
  @redactor.mask_kind(name) || @redactor.query_key?(name)
@@ -13,6 +13,9 @@ module Foam
13
13
  # response or the error.
14
14
  REQUEST_CAPTURE = :foam_request_capture
15
15
 
16
+ # The encodings Excon's Decompress middleware inflates.
17
+ DECOMPRESSED = %w[gzip x-gzip deflate].freeze
18
+
16
19
  def self.install
17
20
  return unless Instrumentations.instance(INSTRUMENTATION).installed?
18
21
 
@@ -75,11 +78,13 @@ module Foam
75
78
  end
76
79
 
77
80
  # Captures the parsed response body. A response handed to a response block never reaches
78
- # the datum, so it is skipped.
81
+ # the datum, so it is skipped. When a middleware above owns the request, it is told
82
+ # instead what Decompress is about to inflate.
79
83
  def capture_response(datum)
80
84
  span = BodyCapture.client_span
81
85
  body = datum.dig(:response, :body)
82
- return unless span && body.is_a?(String) && !datum.key?(:response_block)
86
+ return unless body.is_a?(String) && !datum.key?(:response_block)
87
+ return report_inflating(datum, body) unless span
83
88
 
84
89
  capture = BodyCapture.start(
85
90
  span, State.body_redactor, side: :client, direction: :response
@@ -90,6 +95,18 @@ module Foam
90
95
  capture.finalize(complete: true)
91
96
  end
92
97
 
98
+ # Tells the owning middleware the encoding and size on the wire of a body Decompress is
99
+ # about to inflate. Decompress inflates only the last encoding listed, and only one it
100
+ # knows, then removes it from content-encoding.
101
+ def report_inflating(datum, body)
102
+ return unless datum[:middlewares].include?(::Excon::Middleware::Decompress)
103
+
104
+ encoding = headers(datum[:response][:headers])["content-encoding"]&.split(",")&.last&.strip
105
+ return unless DECOMPRESSED.include?(encoding&.downcase)
106
+
107
+ BodyCapture.inflated_below(encoding, body.bytesize)
108
+ end
109
+
93
110
  # Matched without regard to case, since the request headers keep whatever case the app
94
111
  # wrote.
95
112
  def headers(hash)
@@ -31,6 +31,23 @@ module Foam
31
31
  return unless Instrumentations.instance(INSTRUMENTATION).installed?
32
32
 
33
33
  ::Faraday::Connection.prepend(Connection)
34
+ instrument_default_connection
35
+ end
36
+
37
+ # Faraday.get, Faraday.post and the other class-level calls reuse a cached default
38
+ # connection. If it was built before init (Datadog's integration does this),
39
+ # OpenTelemetry's and our initialize patches never ran on it, so add the tracer and this
40
+ # middleware now, matching what a new connection gets. Once that connection has sent a
41
+ # request, Faraday freezes its middleware, so it is left as is, traced by the HTTP client
42
+ # beneath it.
43
+ def self.instrument_default_connection
44
+ connection = ::Faraday.instance_variable_get(:@default_connection)
45
+ return if connection.nil? || connection.builder.locked?
46
+
47
+ tracer = ::Faraday::Middleware.lookup_middleware(:open_telemetry)
48
+ [tracer, self].each do |middleware|
49
+ connection.use(middleware) unless connection.builder.handlers.any? { |handler| handler.klass == middleware }
50
+ end
34
51
  end
35
52
 
36
53
  def initialize(app)
@@ -39,7 +56,8 @@ module Foam
39
56
 
40
57
  # Captures the request before the adapter runs and the response once it has it. The span
41
58
  # found here is kept for the response side, since inside the owned context client_span
42
- # returns nil by design.
59
+ # returns nil by design. The transport patches beneath report to the owner what they
60
+ # inflated, so the response is described as it was on the wire.
43
61
  def call(env)
44
62
  span = Safely.call("body capture") { BodyCapture.client_span }
45
63
  return @app.call(env) unless span
@@ -51,19 +69,18 @@ module Foam
51
69
  BodyCapture.set_header_attributes(span, :request, request_headers(env, body))
52
70
  end
53
71
  request = Safely.call("request body capture") { capture_request(env, span, body) }
72
+ owner = BodyCapture::Owner.new(span)
54
73
  response = nil
55
74
  begin
56
- response = BodyCapture.owning(span) { @app.call(env) }
75
+ response = BodyCapture.owning(owner) { @app.call(env) }
57
76
  ensure
58
77
  request&.finalize(complete: !response.nil?)
59
78
  end
60
79
  response.on_complete do |completed|
61
80
  Safely.call("response header capture") do
62
- BodyCapture.set_header_attributes(
63
- span, :response, headers(completed.response_headers)
64
- )
81
+ BodyCapture.set_header_attributes(span, :response, sent_headers(completed, owner))
65
82
  end
66
- Safely.call("response body capture") { capture_response(completed, span) }
83
+ Safely.call("response body capture") { capture_response(completed, span, owner) }
67
84
  end
68
85
  end
69
86
 
@@ -91,26 +108,38 @@ module Foam
91
108
  end
92
109
 
93
110
  # Only a String body is the bytes the adapter received: one a middleware below this one has
94
- # already parsed, or one streamed to the app through on_data, is skipped.
95
- def capture_response(env, span)
111
+ # already parsed, or one streamed to the app through on_data, is skipped. A body the
112
+ # transport inflated is reported at its size on the wire.
113
+ def capture_response(env, span, owner)
96
114
  body = env.body
97
115
  return if env.request.stream_response? || !body.is_a?(String)
98
116
 
99
117
  capture = BodyCapture.start(
100
118
  span, State.body_redactor, side: :client, direction: :response
101
- ) { body_headers(env, body) }
119
+ ) { body_headers(env, body, owner) }
102
120
  return unless capture
103
121
 
104
122
  capture.observe(body)
123
+ capture.inflated(owner.wire_bytes) if owner.wire_bytes
105
124
  capture.finalize(complete: true)
106
125
  end
107
126
 
108
- # The safe response headers for the body capture. Every adapter inflates a gzip or deflate
109
- # response for the caller but leaves content-encoding as the server sent it, and that
110
- # header would make the capture try to inflate plain text. It is kept only when the body
111
- # still starts like compressed data.
112
- def body_headers(env, body)
127
+ # The safe response headers as the server sent them. Net::HTTP and Excon remove
128
+ # content-encoding once they inflate the body, so the encoding their patch reported is restored.
129
+ def sent_headers(env, owner)
113
130
  found = headers(env.response_headers)
131
+ found["content-encoding"] = owner.encoding if owner.encoding
132
+ found
133
+ end
134
+
135
+ # The safe response headers for the body capture. When an adapter Foam does not patch
136
+ # inflates the body, content-encoding still names the compression and the capture would
137
+ # try to inflate plain text, so it is dropped unless the body still starts like
138
+ # compressed data. A body a patched transport inflated keeps it.
139
+ def body_headers(env, body, owner)
140
+ found = sent_headers(env, owner)
141
+ return found if owner.encoding
142
+
114
143
  lead = COMPRESSED_LEAD[found["content-encoding"]&.downcase]
115
144
  found.delete("content-encoding") if lead && !body.start_with?(lead)
116
145
  found