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.
- checksums.yaml +4 -4
- data/README.md +85 -9
- data/lib/foam/otel/internal/aws_lambda.rb +1 -1
- data/lib/foam/otel/internal/before_send.rb +12 -8
- data/lib/foam/otel/internal/body_capture.rb +37 -13
- data/lib/foam/otel/internal/body_decoder.rb +27 -2
- data/lib/foam/otel/internal/body_redactor.rb +51 -15
- data/lib/foam/otel/internal/clients/excon.rb +20 -3
- data/lib/foam/otel/internal/clients/faraday.rb +44 -15
- data/lib/foam/otel/internal/clients/http_client.rb +28 -12
- data/lib/foam/otel/internal/clients/net_http.rb +43 -7
- data/lib/foam/otel/internal/constants.rb +3 -0
- data/lib/foam/otel/internal/content_type.rb +13 -0
- data/lib/foam/otel/internal/diagnostics.rb +4 -0
- data/lib/foam/otel/internal/endpoint.rb +1 -1
- data/lib/foam/otel/internal/exporters.rb +36 -6
- data/lib/foam/otel/internal/ingest.rb +55 -11
- data/lib/foam/otel/internal/init.rb +27 -11
- data/lib/foam/otel/internal/instrumentations.rb +10 -2
- data/lib/foam/otel/internal/lifecycle.rb +4 -4
- data/lib/foam/otel/internal/log_record_processor.rb +34 -0
- data/lib/foam/otel/internal/metrics.rb +6 -5
- data/lib/foam/otel/internal/patch_resolver.rb +9 -5
- data/lib/foam/otel/internal/propagation.rb +97 -7
- data/lib/foam/otel/internal/que.rb +45 -0
- data/lib/foam/otel/internal/rails_middleware.rb +1 -1
- data/lib/foam/otel/internal/redactor/keys.rb +4 -0
- data/lib/foam/otel/internal/redactor.rb +181 -59
- data/lib/foam/otel/internal/reinject.rb +4 -3
- data/lib/foam/otel/internal/safely.rb +2 -2
- data/lib/foam/otel/internal/state.rb +27 -3
- data/lib/foam/otel/internal.rb +4 -3
- data/lib/foam/otel/version.rb +1 -1
- data/lib/foam/otel.rb +16 -6
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a99c91ac48b87605a88f36e38e06ceddf80a6af6f029aad030203bc3d09603fb
|
|
4
|
+
data.tar.gz: 840a2d7cbf0baebf643c3f0422c310da434d3dc147c23e0c919792f36b4e3726
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
24
|
-
# beneath it
|
|
25
|
-
|
|
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
|
|
100
|
-
#
|
|
101
|
-
def owning(
|
|
102
|
-
OpenTelemetry::Context.with_value(
|
|
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(
|
|
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],
|
|
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] =>
|
|
237
|
-
CAPTURED_BYTES => text ? text.bytesize :
|
|
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
|
-
|
|
261
|
-
decoded = BodyDecoder.decode(
|
|
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
|
-
|
|
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
|
|
48
|
-
# is left alone, and a failure masks the
|
|
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.
|
|
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
|
-
|
|
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
|
|
308
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)
|