foam-otel 3.0.0.alpha.7 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -9
  3. data/lib/foam/otel/internal/aws_lambda.rb +1 -1
  4. data/lib/foam/otel/internal/before_send.rb +12 -8
  5. data/lib/foam/otel/internal/body_capture.rb +37 -13
  6. data/lib/foam/otel/internal/body_decoder.rb +27 -2
  7. data/lib/foam/otel/internal/body_redactor.rb +51 -15
  8. data/lib/foam/otel/internal/clients/excon.rb +20 -3
  9. data/lib/foam/otel/internal/clients/faraday.rb +44 -15
  10. data/lib/foam/otel/internal/clients/http_client.rb +28 -12
  11. data/lib/foam/otel/internal/clients/net_http.rb +43 -7
  12. data/lib/foam/otel/internal/constants.rb +3 -0
  13. data/lib/foam/otel/internal/content_type.rb +13 -0
  14. data/lib/foam/otel/internal/diagnostics.rb +4 -0
  15. data/lib/foam/otel/internal/endpoint.rb +1 -1
  16. data/lib/foam/otel/internal/exporters.rb +36 -6
  17. data/lib/foam/otel/internal/ingest.rb +55 -11
  18. data/lib/foam/otel/internal/init.rb +27 -11
  19. data/lib/foam/otel/internal/instrumentations.rb +10 -2
  20. data/lib/foam/otel/internal/lifecycle.rb +4 -4
  21. data/lib/foam/otel/internal/log_record_processor.rb +34 -0
  22. data/lib/foam/otel/internal/metrics.rb +6 -5
  23. data/lib/foam/otel/internal/patch_resolver.rb +9 -5
  24. data/lib/foam/otel/internal/propagation.rb +97 -7
  25. data/lib/foam/otel/internal/que.rb +45 -0
  26. data/lib/foam/otel/internal/rails_middleware.rb +1 -1
  27. data/lib/foam/otel/internal/redactor/keys.rb +4 -0
  28. data/lib/foam/otel/internal/redactor.rb +181 -59
  29. data/lib/foam/otel/internal/reinject.rb +4 -3
  30. data/lib/foam/otel/internal/safely.rb +2 -2
  31. data/lib/foam/otel/internal/state.rb +27 -3
  32. data/lib/foam/otel/internal.rb +4 -3
  33. data/lib/foam/otel/version.rb +1 -1
  34. data/lib/foam/otel.rb +16 -6
  35. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 929a47fe32e662ab012e7995078f1b0bab1f0ef3b58e3b7a9ca8b3b109c6ca29
4
- data.tar.gz: 1608559fa17bd0f683492267a2db2f7daa4f3989d4a79f06d67f38e27632174d
3
+ metadata.gz: a99c91ac48b87605a88f36e38e06ceddf80a6af6f029aad030203bc3d09603fb
4
+ data.tar.gz: 840a2d7cbf0baebf643c3f0422c310da434d3dc147c23e0c919792f36b4e3726
5
5
  SHA512:
6
- metadata.gz: b8484befaf21fec24e8ce725931c368d9aaac7518916a8f0aebc69f02a5345fc10a0a73c6b25167d555554133eb84b7f6479d3ad676697beb2c03758352dd592
7
- data.tar.gz: 56032f62347e799791dce8d37db046dd6fe6e4c8e372dd4aff7fa7c54813992b89e61b854b9b6565aeaf9fd7dd08c53747d5c9b8eae897b96ae869622594987e
6
+ metadata.gz: b01ef004f8ee7e8638badb8ee4934b07df03a2f636ec61bbf6a0f1d77fae7e187ddb41dca30c0b0dc667e9a3a1c2d9792bb70fe7f4f38295c0e03e4474d40471
7
+ data.tar.gz: 666a66c3c6a5da5e8d3daf5c50b4536155538bfa321ce82bd6bfa3dcf97f407fac976b81999b50abdd4e0d7d50ac7e3172b55f30f1dca3afbbb223653ca3eea3
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,13 +221,13 @@ 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
 
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
 
@@ -280,7 +291,7 @@ Foam.set_metric("cache.size", 1024, unit: "By", description: "Entries in the cac
280
291
  - `add_up_down_counter(name, value, ...)` adds to a counter that can go down; pass a negative value to decrement.
281
292
  - `set_metric(name, value, ...)` sets a gauge to its latest value.
282
293
 
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.
294
+ 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
295
 
285
296
  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
297
 
@@ -314,7 +325,7 @@ The span gets an `exception` event with the type, message, and stack trace, plus
314
325
 
315
326
  ### `flush(timeout: 10)`
316
327
 
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.
328
+ 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
329
 
319
330
  ```ruby
320
331
  Foam.flush(timeout: 5)
@@ -324,7 +335,7 @@ Foam.flush(timeout: 5)
324
335
 
325
336
  ### `shutdown(timeout: 10)`
326
337
 
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.
338
+ 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
339
 
329
340
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
330
341
 
@@ -367,7 +378,7 @@ Each `signals` value names the source of the Foam export path for that signal:
367
378
  - `:local` - logs only: another SDK owns the global provider and Foam keeps its own so `log` still delivers.
368
379
  - `:none` - no Foam export path.
369
380
 
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`.
381
+ 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
382
 
372
383
  ## Custom spans and other OpenTelemetry APIs
373
384
 
@@ -464,6 +475,71 @@ Foam.init(
464
475
  )
465
476
  ```
466
477
 
478
+ 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:
479
+
480
+ - 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.
481
+ - `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.
482
+
483
+ 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.
484
+
485
+ ### Rollbar
486
+
487
+ 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:
488
+
489
+ ```ruby
490
+ rollbar = URI(Rollbar.configuration.endpoint)
491
+
492
+ Foam.init(
493
+ name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
494
+ ignored_outbound_hosts: ["#{rollbar.host}:#{rollbar.port}"],
495
+ ignored_outbound_headers: ["X-Rollbar-Access-Token"]
496
+ )
497
+ ```
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
+
467
543
  ## Redaction
468
544
 
469
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 `********`.
@@ -576,7 +652,7 @@ OpenTelemetry packages outside the instrumentation bundle that Foam does not wir
576
652
 
577
653
  ## TODO(jhartquist): Safely
578
654
 
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.
655
+ 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
656
 
581
657
  ## Development
582
658
 
@@ -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
@@ -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,8 +13,11 @@ 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
- return unless Instrumentations.instance(INSTRUMENTATION).installed?
20
+ return unless Instrumentations.installed?(INSTRUMENTATION)
18
21
 
19
22
  stack = ::Excon.defaults[:middlewares]
20
23
  return if stack.include?(self)
@@ -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)