foam-otel 3.0.0.alpha.6 → 3.0.0.alpha.7
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 +35 -60
- data/lib/foam/otel/internal/body_capture.rb +13 -11
- data/lib/foam/otel/internal/body_decoder.rb +3 -2
- data/lib/foam/otel/internal/body_redactor.rb +71 -30
- data/lib/foam/otel/internal/constants.rb +2 -7
- data/lib/foam/otel/internal/exporters.rb +8 -1
- data/lib/foam/otel/internal/ignored_hosts.rb +4 -10
- data/lib/foam/otel/internal/redactor.rb +68 -21
- data/lib/foam/otel/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 929a47fe32e662ab012e7995078f1b0bab1f0ef3b58e3b7a9ca8b3b109c6ca29
|
|
4
|
+
data.tar.gz: 1608559fa17bd0f683492267a2db2f7daa4f3989d4a79f06d67f38e27632174d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8484befaf21fec24e8ce725931c368d9aaac7518916a8f0aebc69f02a5345fc10a0a73c6b25167d555554133eb84b7f6479d3ad676697beb2c03758352dd592
|
|
7
|
+
data.tar.gz: 56032f62347e799791dce8d37db046dd6fe6e4c8e372dd4aff7fa7c54813992b89e61b854b9b6565aeaf9fd7dd08c53747d5c9b8eae897b96ae869622594987e
|
data/README.md
CHANGED
|
@@ -40,9 +40,9 @@ Foam.init(
|
|
|
40
40
|
)
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
`init` must run before the app initializes, either with an initializer file or in `config/application.rb`. An `init` from `after_initialize` or later warns on stderr and adds no middleware. During boot it inserts `Foam::Rack::Middleware` directly after the OpenTelemetry Rack middleware. `config.ru` stays as generated.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Do not load Foam with a `RUBYOPT` preload. It runs before Rails loads, so it installs no Rails instrumentation and adds no middleware.
|
|
46
46
|
|
|
47
47
|
### Plain Ruby and Rack
|
|
48
48
|
|
|
@@ -64,7 +64,7 @@ use Foam::Rack::Middleware
|
|
|
64
64
|
run App
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Foam's middleware
|
|
67
|
+
Foam's middleware records the `content-type`, `content-length`, and `content-encoding` headers as server span attributes. In `:advanced` mode it also sends the bodies as log records correlated to the span. See Network capture.
|
|
68
68
|
|
|
69
69
|
## Instrumentation
|
|
70
70
|
|
|
@@ -78,17 +78,13 @@ TODO(jhartquist): Revisit and review this compatibility guidance.
|
|
|
78
78
|
|
|
79
79
|
### Instrumentation versions
|
|
80
80
|
|
|
81
|
-
Bundler keeps one version of each gem
|
|
81
|
+
Bundler keeps one version of each gem, fixed before Foam starts. Each instrumentation checks its library's version when `init` runs and is left out of `state[:instrumentations]` when the version is unsupported. `additional_instrumentations` cannot bypass the check, choose another version, or install a gem.
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
If Bundler accepts the pin, restart the app and the whole bundle will use that instrumentation version. If it conflicts with `foam-otel` or `opentelemetry-instrumentation-all`, those versions cannot live in the same bundle. At that point, change the application library, use a compatible Foam release, or accept the telemetry gap: the app can keep running, but Foam will not receive the spans that instrumentation would have produced.
|
|
86
|
-
|
|
87
|
-
Even a successful `bundle install` only means the declared gem dependencies agree. The instrumentation checks the target library when `init` runs. If the target is unsupported, Foam leaves that instrumentation out of `state[:instrumentations]`. `additional_instrumentations` cannot bypass the check or load a second version of the gem.
|
|
83
|
+
For an unsupported library version, change the app's library, use a compatible Foam release, or accept the gap. The app keeps running, but Foam receives no spans from that library.
|
|
88
84
|
|
|
89
85
|
The `pg`, `mysql2`, and `trilogy` instrumentations obfuscate SQL statements (`db_statement: :obfuscate`, their default), replacing every literal with `?`, so `WHERE email = 'ada@example.com'` is exported as `WHERE email = ?`. A statement over 2000 characters is exported as a placeholder instead. The other instrumentations keep their upstream defaults.
|
|
90
86
|
|
|
91
|
-
To disable one instrumentation or change its options, pass it in `additional_instrumentations` or
|
|
87
|
+
To disable one instrumentation or change its options, pass it in `additional_instrumentations` or set the upstream environment variables `OTEL_RUBY_INSTRUMENTATION_<NAME>_ENABLED=false` and `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS`, which take precedence. `<NAME>` is the instrumentation's registry name after `OpenTelemetry::Instrumentation::`, upper-cased, with `::` replaced by `_`, such as `PG`, `NET_HTTP`, or `ACTIVERECORD`.
|
|
92
88
|
|
|
93
89
|
TODO(jhartquist): `db_capture` init option. The `pg`, `mysql2`, and `trilogy` instrumentations can include the statement (`db_statement: :include`) and `pg` can report row counts (`db_response_returned_rows`, `db_response_affected_rows`), but none hands over results. Result capture needs Foam patches on the drivers, opt-in, through the same redaction and size caps as bodies. See Expanded data capture at the end of this document.
|
|
94
90
|
|
|
@@ -96,13 +92,13 @@ TODO(jhartquist): Gen AI providers beyond `anthropic`. There is no OpenTelemetry
|
|
|
96
92
|
|
|
97
93
|
## Public API
|
|
98
94
|
|
|
99
|
-
Every method lives on the `Foam` module. Other than the ingest factories, 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. 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.
|
|
100
96
|
|
|
101
97
|
### `init`
|
|
102
98
|
|
|
103
99
|
Sets up tracing, metrics, logs, automatic instrumentation, W3C `tracecontext` and `baggage` propagation, and OTLP export to Foam.
|
|
104
100
|
|
|
105
|
-
`init` claims the four OpenTelemetry global slots only when they are free: the tracer provider, meter provider, logger provider, and propagator. A slot another SDK
|
|
101
|
+
`init` claims the four OpenTelemetry global slots only when they are free: the tracer provider, meter provider, logger provider, and propagator. A slot another SDK sets before `init` stays with that SDK. Foam warns once on stderr, reports the signal in `state`, and builds no pipeline for it, so any `additional_*` components for that signal are unused. Foam always builds its own logger provider, so `log` delivers either way. `shutdown` releases the slots that Foam claimed.
|
|
106
102
|
|
|
107
103
|
Call this once. Use it when Foam should run OpenTelemetry for the process.
|
|
108
104
|
|
|
@@ -148,11 +144,11 @@ Foam.init(
|
|
|
148
144
|
- `token` - the Foam ingest token, sent as a bearer token on every export. Required when Foam is enabled; a missing token is reported on stderr and leaves Foam uninitialized.
|
|
149
145
|
- `version` - the service version, sent as `service.version` when given.
|
|
150
146
|
- `sample_rate` - the fraction of root traces kept, from `0.0` to `1.0`; child spans follow their parent. Leave it at `1.0` unless volume forces sampling. `OTEL_TRACES_SAMPLER` is not consulted.
|
|
151
|
-
- `additional_span_processors`, `additional_log_record_processors`, `additional_metric_readers` - the app
|
|
147
|
+
- `additional_span_processors`, `additional_log_record_processors`, `additional_metric_readers` - span processors, log record processors, and metric readers the app builds itself, such as an exporter to another backend. Foam adds them to its providers after its own exporters.
|
|
152
148
|
- `additional_resource_attributes` - attributes merged into the resource of every signal. `service.name`, `service.version`, and `deployment.environment.name` come from the options above and `service.instance.id` from Foam; a value for one of them here is reported on stderr and ignored.
|
|
153
149
|
- `additional_instrumentations` - instrumentations to install next to the bundle. An entry is an instrumentation's registry name, which is its class name without the trailing `::Instrumentation` (`"OpenTelemetry::Instrumentation::Logger"`), or a `[name, config]` pair to install it with options (`["OpenTelemetry::Instrumentation::PG", {db_statement: :include}]`). Require the instrumentation's gem before `init`; an entry whose instrumentation is not loaded fails `init`. An entry naming a bundled instrumentation replaces Foam's configuration for it.
|
|
154
150
|
- `ignored_outbound_hosts` - skips tracing of outbound HTTP requests to these hosts, each written `host` or `host:port`. The endpoint host is always skipped. Use it for hosts whose traffic would drown the traces, such as a sidecar health check on `localhost`, another SDK's agent on `127.0.0.1:8126`, or another vendor's OTLP ingest host. Hosts are compared as written; `localhost` and `127.0.0.1` are different hosts, and an entry with a port matches that port only. A request to a skipped host still carries a `traceparent` marked not sampled, so an OpenTelemetry service behind it records nothing for that request.
|
|
155
|
-
- `ignored_outbound_headers` - skips tracing of outbound Net::HTTP requests carrying any of these request headers, compared case-insensitively.
|
|
151
|
+
- `ignored_outbound_headers` - skips tracing of outbound Net::HTTP requests carrying any of these request headers, compared case-insensitively. Use it for another SDK's traffic to its backend when there is no host to ignore, as over a Unix socket. A connection opened before the request, as `Net::HTTP.start` does, has its connect span before any header is seen, so over TCP ignore the host as well.
|
|
156
152
|
- `disable_log_sending` - `true` stops shipping the app's `Logger` lines to Foam. `Foam.log` is unaffected. See Loggers.
|
|
157
153
|
- `network_capture` - `:off`, `:basic`, or `:advanced`. Use `:advanced` unless security and compliance rule it out. See Network capture.
|
|
158
154
|
- `redact` - keys to mask beyond the built-in list, as `{secrets: [...], pii: [...]}`. A value under a `secrets` key keeps its last four characters when it is 12 characters or longer; a value under a `pii` key is fully masked. See Redaction.
|
|
@@ -170,15 +166,15 @@ Every signal carries a resource with these attributes:
|
|
|
170
166
|
|
|
171
167
|
A process forked after `init` carries its own `service.instance.id` and `process.pid`. `OTEL_RESOURCE_ATTRIBUTES` can add further attributes. It cannot override any of the attributes above, and `OTEL_SERVICE_NAME` cannot override `name`.
|
|
172
168
|
|
|
173
|
-
`init`
|
|
169
|
+
`init` raises only `ArgumentError`, for a missing `name` or `environment` or an unknown keyword. Any other failure, or an option holding an invalid value, is reported on stderr with a `[foam-otel]` prefix and leaves `state` uninitialized.
|
|
174
170
|
|
|
175
171
|
`init` also reports its outcome to Foam: one log record with the message and the `state`, sent straight to the endpoint with the token. The report is sent in the background and never blocks the app. When the process exits before the report has been delivered, exit waits up to two seconds for the send to finish; a report that cannot be delivered is dropped. Nothing is sent without a token.
|
|
176
172
|
|
|
177
173
|
### `set_endpoint(url)`
|
|
178
174
|
|
|
179
|
-
For tests and local development only.
|
|
175
|
+
For tests and local development only. Call it before `init` to send every export to another OTLP/HTTP base URL, without a signal path, such as a local collector. The default is Foam's ingest endpoint, `https://otel.api.foam.ai`.
|
|
180
176
|
|
|
181
|
-
|
|
177
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and their per-signal forms are not consulted. The upstream exporters still read the timeout, compression, and certificate variables.
|
|
182
178
|
|
|
183
179
|
### `create_foam_ingest_span_processor(name, environment, token, redact: nil, before_send: nil)`
|
|
184
180
|
|
|
@@ -225,7 +221,7 @@ All three export to the endpoint set by `set_endpoint` and take the same `redact
|
|
|
225
221
|
|
|
226
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.
|
|
227
223
|
|
|
228
|
-
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
|
+
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.
|
|
229
225
|
|
|
230
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.
|
|
231
227
|
|
|
@@ -256,7 +252,7 @@ end
|
|
|
256
252
|
|
|
257
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.
|
|
258
254
|
|
|
259
|
-
On
|
|
255
|
+
On outbound requests, Foam writes its propagation headers after any other SDK's. Another SDK's own propagation headers are left as written.
|
|
260
256
|
|
|
261
257
|
### `set_baggage(key, value)`
|
|
262
258
|
|
|
@@ -284,13 +280,13 @@ Foam.set_metric("cache.size", 1024, unit: "By", description: "Entries in the cac
|
|
|
284
280
|
- `add_up_down_counter(name, value, ...)` adds to a counter that can go down; pass a negative value to decrement.
|
|
285
281
|
- `set_metric(name, value, ...)` sets a gauge to its latest value.
|
|
286
282
|
|
|
287
|
-
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.
|
|
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.
|
|
288
284
|
|
|
289
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.
|
|
290
286
|
|
|
291
287
|
### `log(body, severity: :info, attributes: {})`
|
|
292
288
|
|
|
293
|
-
Sends a log record to Foam on Foam's own export path. The record goes only to Foam: nothing is written to the app's log output, and nothing is emitted through a logger provider Foam does not own. A record emitted inside a request carries the trace and span
|
|
289
|
+
Sends a log record to Foam on Foam's own export path. The record goes only to Foam: nothing is written to the app's log output, and nothing is emitted through a logger provider Foam does not own. A record emitted inside a request carries the trace and span IDs of the current span.
|
|
294
290
|
|
|
295
291
|
Use it to send a log directly, for example when the app's logger is not captured automatically (see Loggers).
|
|
296
292
|
|
|
@@ -332,7 +328,7 @@ Flushes and shuts down Foam's providers, including the one behind `log` when an
|
|
|
332
328
|
|
|
333
329
|
`timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
|
|
334
330
|
|
|
335
|
-
`at_exit` covers a normal exit, an unhandled exception, and a signal the process handles by exiting. `exit!`, `KILL
|
|
331
|
+
`at_exit` covers a normal exit, an unhandled exception, and a signal the process handles by exiting. The last batch is lost when the process ends without running `at_exit`, as with `exit!`, a `KILL` signal, or a crash of the Ruby process itself, and when a batch outlasts the timeout. An exception outside any span is invisible unless the app logs it.
|
|
336
332
|
|
|
337
333
|
Foam's span processor holds 4096 finished spans and drops the oldest when full; `OTEL_BSP_MAX_QUEUE_SIZE` overrides the size. The ingest span processor and the log record processors keep the OpenTelemetry default of 2048.
|
|
338
334
|
|
|
@@ -371,7 +367,7 @@ Each `signals` value names the source of the Foam export path for that signal:
|
|
|
371
367
|
- `:local` - logs only: another SDK owns the global provider and Foam keeps its own so `log` still delivers.
|
|
372
368
|
- `:none` - no Foam export path.
|
|
373
369
|
|
|
374
|
-
A slot another SDK held before `init` stays with it and reports `:none` (`:local` for logs)
|
|
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`.
|
|
375
371
|
|
|
376
372
|
## Custom spans and other OpenTelemetry APIs
|
|
377
373
|
|
|
@@ -387,56 +383,38 @@ TODO(jhartquist): custom spans and the OpenTelemetry API: getting a tracer, `in_
|
|
|
387
383
|
|
|
388
384
|
There is no option to record other headers.
|
|
389
385
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
Server spans get the headers from `Foam::Rack::Middleware`, placed after the OpenTelemetry Rack middleware (added by `init` in Rails apps).
|
|
386
|
+
Server spans get the capture from `Foam::Rack::Middleware`, placed after the OpenTelemetry Rack middleware (added by `init` in Rails apps). Client spans get it from `Net::HTTP`, Faraday with any adapter, Excon, and HTTPClient when their gem is loaded before `init`. RestClient and HTTParty are captured on the `Net::HTTP` span beneath them. `http`, `httpx`, and `ethon` get no capture, and neither does a Faraday connection built before `init`.
|
|
393
387
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
Faraday over the `httpclient` adapter sends each request with the trace marked not sampled, so a Foam server on the other side records no span for it. The issue is in the upstream HTTPClient instrumentation, which ignores the untraced context Faraday runs its adapter in; Net::HTTP and Excon honor it.
|
|
388
|
+
Streamed bodies are captured for a Rack response the server iterates, a Rack request body as the app reads it, a `Net::HTTP` request sent through `body_stream`, and an HTTPClient response read in a block. Other streamed bodies are not, such as a Rack `call` body, a file sent by path, a hijacked connection, a client request body sent from an IO, or a client response read in a block or through a streaming callback.
|
|
397
389
|
|
|
398
390
|
TODO(jhartquist): cover `http`, `httpx`, `ethon`, and `restclient`.
|
|
399
391
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
### Body capture
|
|
403
|
-
|
|
404
|
-
In `:advanced` mode each captured body arrives as `foam.http.body.chunk` log records correlated to the span, and the span carries the body's size and content and whether the capture was complete.
|
|
392
|
+
### Bodies
|
|
405
393
|
|
|
394
|
+
- Each captured body arrives as `foam.http.body.chunk` log records correlated to the span, each carrying a chunk of the body's text as its log body. The span carries the body's size and whether the capture was complete.
|
|
406
395
|
- Bodies with a textual content type, or one declaring a charset, are captured. gzip and deflate are decoded. Other encodings and binary types yield one record with the size only; an empty body yields none.
|
|
407
396
|
- At most 1 MiB of a body is kept, sent as records of at most 64 KiB each. The rest counts toward the size.
|
|
408
|
-
- Bodies are parsed by content type and redacted with the same keys as span attributes.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
Server bodies need `Foam::Rack::Middleware` after the OpenTelemetry Rack middleware, as shown under "Plain Ruby and Rack" (added by `init` in Rails apps).
|
|
397
|
+
- Bodies are parsed by content type and redacted with the same keys as span attributes. See Redaction.
|
|
398
|
+
- A server request body is captured whether or not the app reads it when the input is rewindable, as it is under Puma. Otherwise only what the app reads is captured.
|
|
399
|
+
- A streamed server response is observed as it is sent, without buffering or delaying it. Its records are emitted when the body closes; a stream idle for over 60 seconds is closed as incomplete and the rest is dropped.
|
|
413
400
|
|
|
414
|
-
|
|
415
|
-
- A streamed response is observed as the server sends it, without buffering or delaying it. The records are emitted when the body closes; a stream idle for over 60 seconds is closed as incomplete and the rest is dropped.
|
|
416
|
-
- A streaming body (`call`), a file sent by path (`to_path`), and a hijacked connection are not captured.
|
|
401
|
+
### Known issue
|
|
417
402
|
|
|
418
|
-
|
|
403
|
+
Faraday over the `httpclient` adapter sends each request with the trace marked not sampled, so a Foam service on the other side records no span for it. Faraday over `Net::HTTP` or Excon is unaffected.
|
|
419
404
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
- `Net::HTTP`: a request is captured whether sent as a string or through `body_stream`. A response read in a block or into an IO is not captured.
|
|
423
|
-
- Faraday: bodies are captured on the Faraday span through Foam's Faraday middleware, whatever the adapter. A request whose body is an IO and a response streamed with `on_data` are not captured.
|
|
424
|
-
- Excon: a request sent with `request_block` and a response received with `response_block` are not captured.
|
|
425
|
-
- HTTPClient: a request whose body is an IO or multipart parts is not captured. A response read in a block is captured.
|
|
426
|
-
- RestClient and HTTParty: captured on the `Net::HTTP` span beneath them.
|
|
427
|
-
- `http`, `httpx`, and `ethon`: no bodies, since they get no header capture either.
|
|
405
|
+
TODO(jhartquist): the HTTPClient trace break above. The fix is `return super if OpenTelemetry::Common::Utilities.untraced?` at the top of the instrumentation's `do_get_block`, upstream or redefined at init until a release carries it.
|
|
428
406
|
|
|
429
407
|
TODO(jhartquist): capture the streamed bodies listed above. For `Net::HTTP`, prepend on `Net::HTTPResponse#read_body` and tee the block or IO the app passes.
|
|
430
408
|
|
|
431
409
|
## Loggers
|
|
432
410
|
|
|
433
|
-
Foam installs `opentelemetry-instrumentation-logger`,
|
|
411
|
+
Foam installs `opentelemetry-instrumentation-logger`, so every logger built on `Logger` is covered, including one created before `init`. Each line becomes one log record: the body is the formatted line and the severity follows the `Logger` level, except that `unknown` arrives with no severity. The app's own log output is unchanged; the trace and span IDs are on the record sent to Foam, not written into the app's lines. The record carries the resource attributes but none of its own: a JSON-formatted line arrives as one string body, with its fields inside the text. Use `Foam.log` to send a record with attributes.
|
|
434
412
|
|
|
435
413
|
Covered:
|
|
436
414
|
|
|
437
415
|
- `Logger`: every line except `Logger#<<`, which writes to the device directly.
|
|
438
416
|
- Rails 7.1 and later: `Rails.logger`, `ActiveSupport::Logger`, `TaggedLogging`, and `BroadcastLogger`. A broadcast sends one record per line, tags included in the body, provided ActiveSupport is loaded before `init`; otherwise every sink sends its own record.
|
|
439
|
-
- `Sidekiq::Logger`, MonoLogger, LogStashLogger, Ougai, and fluent-logger build on `Logger` and
|
|
417
|
+
- `Sidekiq::Logger`, MonoLogger, LogStashLogger, Ougai, and fluent-logger build on `Logger` and are covered; Lograge formats `Rails.logger` lines and is covered through it. LogStashLogger's `MultiLogger` sends one record per logger it wraps.
|
|
440
418
|
|
|
441
419
|
Not covered, since they do not build on `Logger`: Semantic Logger, `logging`, log4r, yell, and Puma's own server lines. Use `Foam.log` for a record from these. Semantic Logger also ships an `:open_telemetry` appender. `puts` and direct writes to `$stdout` or `$stderr` are never captured, nor is a `Logger` whose device is `nil` or `File::NULL`.
|
|
442
420
|
|
|
@@ -488,17 +466,17 @@ Foam.init(
|
|
|
488
466
|
|
|
489
467
|
## Redaction
|
|
490
468
|
|
|
491
|
-
Foam masks the values of sensitive keys in everything it exports: span, span event, and span link attributes; log bodies and attributes; metric data point attributes; and resource attributes. A built-in list of credential and personal-data keys always applies, matched by exact name or by a sensitive segment of the key (`legacy_api_key_2`, `stripeToken`). Keys match after normalization: case, `-`, `.`, and camelCase boundaries are folded, so `apiKey`, `api-key`, and `API_KEY` are one key. Header attributes match on the header name. SQL statements arrive from the database instrumentations with every literal replaced with `?` (see Instrumentation) and are redacted like any other string. Captured HTTP bodies are masked by content type with the same keys
|
|
469
|
+
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 `********`.
|
|
492
470
|
|
|
493
471
|
Every other string is scanned as text, log bodies included.
|
|
494
472
|
|
|
495
473
|
- A string that is a whole JSON document is masked by key at every depth. A list or object under a sensitive key becomes one mask.
|
|
496
|
-
- Any other string has the value after each sensitive key masked. The key may be quoted as JSON writes it or bare before `=`, `:`, or `=>`, in a query string, a header line, or prose. The value ends at the next delimiter
|
|
474
|
+
- Any other string has the value after each sensitive key masked. The key may be quoted as JSON writes it or bare before `=`, `:`, or `=>`, in a query string, a header line, or prose. The value ends at the next delimiter.
|
|
497
475
|
- `url.query` is masked pair by pair. Names such as `code` and `sig` are sensitive only inside a query string.
|
|
498
476
|
|
|
499
477
|
Scanning stops after 1 MiB, and everything past that point is replaced with a single mask. Masking is value-only: the key and the surrounding text remain intact.
|
|
500
478
|
|
|
501
|
-
The `redact` option adds the app's own keys, matched exactly after the same normalization. A value under a built-in or `pii` key becomes `[REDACTED]`. A value under a `secrets` key becomes `********`, followed by its last four characters when it is 12 characters or longer.
|
|
479
|
+
The `redact` option adds the app's own keys, matched exactly after the same normalization. A value under a built-in or `pii` key becomes `[REDACTED]`. A value under a `secrets` key becomes `********`, followed by its last four characters when it is 12 characters or longer.
|
|
502
480
|
|
|
503
481
|
Masking changes values only: keys stay present, and span names, event names, and severity are untouched. It is applied after `before_send`, to the copy sent to Foam, through `init` and through the ingest components alike. The app's own span and log objects, and the copies handed to other processors and exporters, are unchanged.
|
|
504
482
|
|
|
@@ -605,11 +583,8 @@ Every public method except the ingest factories, and every capture hook, runs un
|
|
|
605
583
|
```sh
|
|
606
584
|
bundle install
|
|
607
585
|
bundle exec rspec # unit tests
|
|
608
|
-
bundle exec rake e2e # end-to-end tests
|
|
609
586
|
bundle exec standardrb --fix # lint and autoformat
|
|
610
|
-
bundle exec rake # unit,
|
|
587
|
+
bundle exec rake # unit, then lint (what CI checks)
|
|
611
588
|
```
|
|
612
589
|
|
|
613
|
-
The end-to-end tests run a real OTLP collector and the fixture apps under `e2e/apps/` with puma, then assert on what the collector received. `rake e2e` downloads the pinned `otelcol-contrib` release to `~/.cache/foam-otel` the first time (about 350 MB; set `FOAM_OTELCOL_DIR` to cache it elsewhere), and the suite installs a fixture app's bundle when it starts the app. Run one file with `bundle exec rspec e2e/spec/rack_init_spec.rb`; that does not download the collector, so run `rake e2e:install` once first.
|
|
614
590
|
|
|
615
|
-
Each run writes the collector config, the received telemetry, and the output of both processes to `tmp/e2e/`. A failure prints the tail of both stderr files.
|
|
@@ -19,18 +19,16 @@ module Foam
|
|
|
19
19
|
CHUNK_COUNT = "foam.http.body.chunk.count"
|
|
20
20
|
CHUNK_INDEX = "foam.http.body.chunk.index"
|
|
21
21
|
CHUNK_BYTES = "foam.http.body.chunk.bytes"
|
|
22
|
-
CHUNK_CONTENT = "foam.http.body.chunk.content"
|
|
23
22
|
|
|
24
23
|
# The context key a capturing middleware marks its span under, so the transport patches
|
|
25
24
|
# beneath it leave that span alone.
|
|
26
25
|
OWNED_SPAN = OpenTelemetry::Context.create_key("foam-otel.owned-span")
|
|
27
26
|
|
|
28
|
-
# The span attributes of each direction. Size
|
|
29
|
-
#
|
|
27
|
+
# The span attributes of each direction. Size and the header prefix are the semantic
|
|
28
|
+
# conventions' names; the rest are Foam's.
|
|
30
29
|
SPAN_ATTRIBUTES = %i[request response].to_h do |direction|
|
|
31
30
|
[direction, {
|
|
32
31
|
size: "http.#{direction}.body.size",
|
|
33
|
-
content: "http.#{direction}.body.content",
|
|
34
32
|
capture_id: "foam.http.#{direction}.body.capture_id",
|
|
35
33
|
complete: "foam.http.#{direction}.body.complete",
|
|
36
34
|
truncated: "foam.http.#{direction}.body.truncated",
|
|
@@ -159,12 +157,16 @@ module Foam
|
|
|
159
157
|
@last_activity = BodyCapture.now
|
|
160
158
|
@observed_bytes += bytes.bytesize
|
|
161
159
|
room = Constants::BODY_CAPTURE_BYTES - @kept_bytes
|
|
160
|
+
# The caller's string is copied, since the app may still write to it; a slice is a
|
|
161
|
+
# fresh string already, so it is relabeled as bytes rather than copied again.
|
|
162
162
|
if bytes.bytesize > room
|
|
163
163
|
@truncated = true
|
|
164
|
-
|
|
164
|
+
kept = bytes.byteslice(0, room).force_encoding(Encoding::BINARY)
|
|
165
|
+
else
|
|
166
|
+
kept = bytes.b
|
|
165
167
|
end
|
|
166
|
-
@chunks <<
|
|
167
|
-
@kept_bytes +=
|
|
168
|
+
@chunks << kept unless kept.empty?
|
|
169
|
+
@kept_bytes += kept.bytesize
|
|
168
170
|
nil
|
|
169
171
|
end
|
|
170
172
|
end
|
|
@@ -220,7 +222,6 @@ module Foam
|
|
|
220
222
|
@span.set_attribute(@attributes[:complete], complete)
|
|
221
223
|
@span.set_attribute(@attributes[:truncated], truncated)
|
|
222
224
|
@span.set_attribute(@attributes[:size], @observed_bytes)
|
|
223
|
-
@span.set_attribute(@attributes[:content], text) if text
|
|
224
225
|
end
|
|
225
226
|
return if outcome == "empty"
|
|
226
227
|
|
|
@@ -243,7 +244,7 @@ module Foam
|
|
|
243
244
|
records.each_with_index do |chunk, index|
|
|
244
245
|
logger.on_emit(
|
|
245
246
|
event_name: Constants::BODY_CHUNK_EVENT,
|
|
246
|
-
body:
|
|
247
|
+
body: chunk,
|
|
247
248
|
severity_number: Logs::SEVERITIES[:info],
|
|
248
249
|
severity_text: "INFO",
|
|
249
250
|
attributes: chunk ? common.merge(chunk_attributes(chunk, index)) : common,
|
|
@@ -256,7 +257,8 @@ module Foam
|
|
|
256
257
|
# body cannot be read as text: an unknown or corrupt encoding, one inflating past the
|
|
257
258
|
# bound, or a binary content type. Only the metadata is exported then, never the bytes.
|
|
258
259
|
def decoded_text(headers)
|
|
259
|
-
|
|
260
|
+
wire = (@chunks.size == 1) ? @chunks.first : @chunks.join
|
|
261
|
+
decoded = BodyDecoder.decode(wire, headers["content-encoding"])
|
|
260
262
|
return [nil, false] unless decoded
|
|
261
263
|
|
|
262
264
|
BodyDecoder.text(decoded, ContentType.parse(headers["content-type"])) || [nil, false]
|
|
@@ -283,7 +285,7 @@ module Foam
|
|
|
283
285
|
end
|
|
284
286
|
|
|
285
287
|
def chunk_attributes(chunk, index)
|
|
286
|
-
{CHUNK_INDEX => index, CHUNK_BYTES => chunk.bytesize
|
|
288
|
+
{CHUNK_INDEX => index, CHUNK_BYTES => chunk.bytesize}
|
|
287
289
|
end
|
|
288
290
|
end
|
|
289
291
|
end
|
|
@@ -31,10 +31,11 @@ module Foam
|
|
|
31
31
|
return unless content_type.text?
|
|
32
32
|
|
|
33
33
|
cut = bytes.bytesize > Constants::BODY_CAPTURE_BYTES
|
|
34
|
-
text = bytes.byteslice(0, Constants::BODY_CAPTURE_BYTES)
|
|
34
|
+
text = cut ? bytes.byteslice(0, Constants::BODY_CAPTURE_BYTES) : bytes.dup
|
|
35
35
|
text.force_encoding(encoding_for(content_type))
|
|
36
36
|
text = if text.encoding == Encoding::UTF_8
|
|
37
|
-
whole_utf8(text)
|
|
37
|
+
whole = whole_utf8(text)
|
|
38
|
+
whole.valid_encoding? ? whole : whole.scrub
|
|
38
39
|
else
|
|
39
40
|
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
40
41
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "strscan"
|
|
4
5
|
|
|
5
6
|
module Foam
|
|
6
7
|
module Otel
|
|
@@ -13,10 +14,16 @@ module Foam
|
|
|
13
14
|
BLANK_LINE = /(\r\n\r\n|\n\n|\r\r)/
|
|
14
15
|
RECORD_SEPARATOR = "\u001e"
|
|
15
16
|
|
|
16
|
-
# A JSON
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
# A JSON key and its separator. The key is bounded so the pattern never walks a long string;
|
|
18
|
+
# the scalar after it is read by position, which costs nothing however long it is.
|
|
19
|
+
JSON_KEY = /"((?:[^"\\]|\\.){,#{Constants::KEY_SCAN_CHARS}})"(\s*:\s*)/
|
|
20
|
+
JSON_NUMBER_END = /[^\d.eE+-]/
|
|
21
|
+
JSON_LITERALS = %w[true false null].freeze
|
|
22
|
+
QUOTE = '"'.ord
|
|
23
|
+
MINUS = "-".ord
|
|
24
|
+
CLOSE_BRACKET = ">".ord
|
|
25
|
+
WHITESPACE = [" ", "\t", "\n", "\r", "\f", "\v"].map(&:ord).freeze
|
|
26
|
+
DIGITS = ("0".ord.."9".ord)
|
|
20
27
|
SSE_DATA_LINE = /\A(data: ?)(.*)\z/
|
|
21
28
|
XML_OPENING_TAG = /<([A-Za-z_][\w:.-]*)(?:\s[^>]*)?>/
|
|
22
29
|
XML_ATTRIBUTE = /(\s)([A-Za-z_:][\w:.-]*)(\s*=\s*)(["'])(.*?)\4/
|
|
@@ -104,18 +111,38 @@ module Foam
|
|
|
104
111
|
end
|
|
105
112
|
|
|
106
113
|
# Sensitive pairs masked as written, for JSON that does not parse; a truncated body is the
|
|
107
|
-
# usual case. The mask is the plain one, since the value may be cut.
|
|
114
|
+
# usual case. The mask is the plain one, since the value may be cut. Every scalar is stepped
|
|
115
|
+
# over by position, so no pattern is tried inside a value.
|
|
108
116
|
def redact_json_fallback(text)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
scanner = StringScanner.new(text)
|
|
118
|
+
out = nil
|
|
119
|
+
last = 0
|
|
120
|
+
while scanner.scan_until(JSON_KEY)
|
|
121
|
+
key, separator = scanner[1], scanner[2]
|
|
122
|
+
pair_start = scanner.pos - scanner.matched_size
|
|
123
|
+
value_end = json_scalar_end(text, scanner.pos) or next
|
|
124
|
+
scanner.pos = value_end
|
|
125
|
+
kind = @redactor.mask_kind(key) or next
|
|
126
|
+
|
|
127
|
+
out ||= +""
|
|
128
|
+
out << text.byteslice(last...pair_start) << "\"#{key}\"#{separator}\"#{fallback_mask(kind)}\""
|
|
129
|
+
last = value_end
|
|
130
|
+
end
|
|
131
|
+
out << text.byteslice(last..) if out
|
|
132
|
+
end
|
|
114
133
|
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
# The position after the JSON scalar at position, or nil when no scalar starts there. A
|
|
135
|
+
# string cut off by truncation ends with the text.
|
|
136
|
+
def json_scalar_end(text, position)
|
|
137
|
+
byte = text.getbyte(position)
|
|
138
|
+
if byte == QUOTE
|
|
139
|
+
Redactor.closing_quote(text, position + 1, QUOTE, text.bytesize) || text.bytesize
|
|
140
|
+
elsif byte == MINUS || DIGITS.cover?(byte)
|
|
141
|
+
text.byteindex(JSON_NUMBER_END, position) || text.bytesize
|
|
142
|
+
else
|
|
143
|
+
literal = JSON_LITERALS.find { |word| text.byteslice(position, word.bytesize) == word }
|
|
144
|
+
literal && position + literal.bytesize
|
|
117
145
|
end
|
|
118
|
-
redacted if changed
|
|
119
146
|
end
|
|
120
147
|
|
|
121
148
|
# The mask for a value that cannot be read whole: no tail is kept for a secret.
|
|
@@ -227,40 +254,54 @@ module Foam
|
|
|
227
254
|
end
|
|
228
255
|
|
|
229
256
|
# The text of each sensitive element is masked; one holding markup or never closed gets the
|
|
230
|
-
# plain mask, since its value cannot be read whole.
|
|
257
|
+
# plain mask, since its value cannot be read whole. Positions are bytes throughout, so the
|
|
258
|
+
# body's length costs nothing.
|
|
231
259
|
def redact_xml_elements(text)
|
|
232
|
-
|
|
260
|
+
scanner = StringScanner.new(text, fixed_anchor: true)
|
|
261
|
+
output = nil
|
|
233
262
|
cursor = 0
|
|
234
|
-
scan_from = 0
|
|
235
|
-
output = +""
|
|
236
263
|
|
|
237
|
-
while (
|
|
238
|
-
|
|
239
|
-
next if opening[0].end_with?("/>")
|
|
264
|
+
while scanner.scan_until(XML_OPENING_TAG)
|
|
265
|
+
next if scanner.matched.end_with?("/>")
|
|
240
266
|
|
|
241
|
-
|
|
267
|
+
name = scanner[1]
|
|
268
|
+
kind = @redactor.mask_kind(local_name(name))
|
|
242
269
|
next unless kind
|
|
243
270
|
|
|
244
|
-
|
|
245
|
-
output << text
|
|
246
|
-
closing =
|
|
271
|
+
output ||= +""
|
|
272
|
+
output << text.byteslice(cursor...scanner.pos)
|
|
273
|
+
closing = xml_closing_tag(text, name, scanner.pos)
|
|
247
274
|
unless closing
|
|
248
275
|
output << fallback_mask(kind)
|
|
249
|
-
cursor = text.
|
|
276
|
+
cursor = text.bytesize
|
|
250
277
|
break
|
|
251
278
|
end
|
|
252
279
|
|
|
253
|
-
value = text
|
|
280
|
+
value = text.byteslice(scanner.pos...closing.begin)
|
|
254
281
|
masked = value.include?("<") ? fallback_mask(kind) : @redactor.mask(kind, value)
|
|
255
|
-
output << masked << closing
|
|
256
|
-
cursor =
|
|
282
|
+
output << masked << text.byteslice(closing)
|
|
283
|
+
cursor = scanner.pos = closing.end
|
|
257
284
|
end
|
|
258
285
|
|
|
259
|
-
output << text
|
|
286
|
+
output << text.byteslice(cursor..) if output
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The byte range of the first </name> at or after position, whitespace before the bracket
|
|
290
|
+
# allowed; nil when the element is never closed.
|
|
291
|
+
def xml_closing_tag(text, name, position)
|
|
292
|
+
needle = "</#{name}"
|
|
293
|
+
while (start = text.byteindex(needle, position))
|
|
294
|
+
stop = start + needle.bytesize
|
|
295
|
+
stop += 1 while WHITESPACE.include?(text.getbyte(stop))
|
|
296
|
+
return start...(stop + 1) if text.getbyte(stop) == CLOSE_BRACKET
|
|
297
|
+
|
|
298
|
+
position = start + 1
|
|
299
|
+
end
|
|
260
300
|
end
|
|
261
301
|
|
|
262
302
|
def local_name(name)
|
|
263
|
-
name.
|
|
303
|
+
colon = name.rindex(":")
|
|
304
|
+
colon ? name[(colon + 1)..] : name
|
|
264
305
|
end
|
|
265
306
|
|
|
266
307
|
# A sensitive key's value is masked on its line; a block scalar under one spans lines that
|
|
@@ -22,11 +22,6 @@ module Foam
|
|
|
22
22
|
# The prefixes of the header attributes, http.request.header.<name> and http.response.header.<name>.
|
|
23
23
|
HTTP_HEADER_PREFIXES = ["http.request.header.", "http.response.header."].freeze
|
|
24
24
|
|
|
25
|
-
# Captured body content on a span. Foam names these; the semantic conventions define only the
|
|
26
|
-
# body sizes.
|
|
27
|
-
HTTP_REQUEST_BODY_CONTENT = "http.request.body.content"
|
|
28
|
-
HTTP_RESPONSE_BODY_CONTENT = "http.response.body.content"
|
|
29
|
-
|
|
30
25
|
# The only HTTP headers captured as span attributes: they describe the body, never the caller.
|
|
31
26
|
SAFE_NETWORK_HEADERS = %w[content-type content-length content-encoding].freeze
|
|
32
27
|
|
|
@@ -49,8 +44,8 @@ module Foam
|
|
|
49
44
|
# The event name on every body chunk record.
|
|
50
45
|
BODY_CHUNK_EVENT = "foam.http.body.chunk"
|
|
51
46
|
|
|
52
|
-
# The
|
|
53
|
-
|
|
47
|
+
# The longest key the redactors match in text.
|
|
48
|
+
KEY_SCAN_CHARS = 256
|
|
54
49
|
|
|
55
50
|
# Replaces a redacted value.
|
|
56
51
|
REDACTED_VALUE = "[REDACTED]"
|
|
@@ -138,7 +138,7 @@ module Foam
|
|
|
138
138
|
event = @before_send.event_to_export(hook_event(record)) or return
|
|
139
139
|
|
|
140
140
|
record.dup.tap do |copy|
|
|
141
|
-
copy.body =
|
|
141
|
+
copy.body = redacted_body(record, event[:body])
|
|
142
142
|
copy.severity_text = event[:severity_text]
|
|
143
143
|
copy.severity_number = event[:severity_number]
|
|
144
144
|
copy.attributes = redacted_attributes(event[:attributes])
|
|
@@ -148,6 +148,13 @@ module Foam
|
|
|
148
148
|
end
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
# A body chunk's text was redacted by the body redactor when it was captured.
|
|
152
|
+
def redacted_body(record, body)
|
|
153
|
+
return body if record.event_name == Constants::BODY_CHUNK_EVENT
|
|
154
|
+
|
|
155
|
+
@redactor.redact_value(body)
|
|
156
|
+
end
|
|
157
|
+
|
|
151
158
|
# What the hook sees: the fields it may edit, then the correlation fields, which are read from
|
|
152
159
|
# the record on export whatever the hook returns.
|
|
153
160
|
def hook_event(record)
|
|
@@ -11,14 +11,8 @@ module Foam
|
|
|
11
11
|
# never records, so no processor sees it.
|
|
12
12
|
class IgnoredHosts
|
|
13
13
|
# Where the client instrumentations put the host and port when the span starts.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
OpenTelemetry::SemConv::Incubating::NET::NET_PEER_NAME
|
|
17
|
-
].freeze
|
|
18
|
-
PORT_KEYS = [
|
|
19
|
-
OpenTelemetry::SemConv::SERVER::SERVER_PORT,
|
|
20
|
-
OpenTelemetry::SemConv::Incubating::NET::NET_PEER_PORT
|
|
21
|
-
].freeze
|
|
14
|
+
HOST_KEY = OpenTelemetry::SemConv::SERVER::SERVER_ADDRESS
|
|
15
|
+
PORT_KEY = OpenTelemetry::SemConv::SERVER::SERVER_PORT
|
|
22
16
|
|
|
23
17
|
# Skipped: server spans carry the local host in server.address.
|
|
24
18
|
INBOUND_KINDS = %i[server consumer].freeze
|
|
@@ -59,8 +53,8 @@ module Foam
|
|
|
59
53
|
return false if attributes.nil? || INBOUND_KINDS.include?(kind)
|
|
60
54
|
|
|
61
55
|
# The host and port the client instrumentation set, if it set them.
|
|
62
|
-
host = attributes
|
|
63
|
-
port = attributes
|
|
56
|
+
host = attributes[HOST_KEY]
|
|
57
|
+
port = attributes[PORT_KEY]
|
|
64
58
|
# The rest-client gem's instrumentation sets no host, only the URL.
|
|
65
59
|
url = parsed(attributes[OpenTelemetry::SemConv::URL::URL_FULL])
|
|
66
60
|
host ||= url&.host
|
|
@@ -27,25 +27,33 @@ module Foam
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
FLOOR_KEYS = Keys::SENSITIVE_KEYS.to_set { |key| normalize_key(key) }.freeze
|
|
30
|
-
|
|
30
|
+
# The one-word segments, and the two-word ones keyed by their first word.
|
|
31
|
+
FLOOR_WORDS = Keys::SENSITIVE_KEY_SEGMENTS.grep_v(/_/).to_set.freeze
|
|
32
|
+
FLOOR_PAIRS = Keys::SENSITIVE_KEY_SEGMENTS.grep(/_/).map { |segment| segment.split("_", 2) }
|
|
33
|
+
.group_by(&:first).transform_values { |pairs| pairs.map(&:last).to_set.freeze }.freeze
|
|
31
34
|
HTTP_HEADERS = Keys::SENSITIVE_HTTP_HEADERS.to_set { |header| normalize_key(header) }.freeze
|
|
32
35
|
QUERY_KEYS = Keys::SENSITIVE_QUERY_KEYS.to_set { |key| normalize_key(key) }.freeze
|
|
33
|
-
BODY_KEYS = [Constants::HTTP_REQUEST_BODY_CONTENT, Constants::HTTP_RESPONSE_BODY_CONTENT].freeze
|
|
34
36
|
FIELDS = %w[secrets pii].freeze
|
|
35
37
|
QUERY_PAIR_CAP = 256
|
|
36
38
|
SCAN_BYTE_CAP = Constants::BODY_CAPTURE_BYTES
|
|
37
39
|
MAX_DEPTH = 10
|
|
38
40
|
|
|
39
|
-
# A key is quoted, as in JSON or a Ruby hash, or bare, as in a log line or a query.
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# A key is quoted, as in JSON or a Ruby hash, or bare, as in a log line or a query. Keys are
|
|
42
|
+
# bounded so no pattern walks a long string: the regex engine keeps a backtrack entry per
|
|
43
|
+
# repetition, and a megabyte value would cost as much memory and milliseconds of time.
|
|
44
|
+
JSON_KEY = /"((?:[^"\\\n]|\\.){,#{Constants::KEY_SCAN_CHARS}})"(\s*(?:=>|:)\s*)/
|
|
45
|
+
BARE_KEY = /(?<![\w.%])([A-Za-z_][\w.\-%]{,#{Constants::KEY_SCAN_CHARS}})([ \t]*(?:=>|={1,3}|:)[ \t]*)/
|
|
43
46
|
# Both key shapes in one pattern, so one pass visits every key in source order. Groups 1 and 2
|
|
44
47
|
# are a quoted key and its separator, groups 3 and 4 a bare one.
|
|
45
48
|
KEY = Regexp.union(JSON_KEY, BARE_KEY)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
# A value is found by position, never matched: an auth scheme word like Bearer is part of it,
|
|
50
|
+
# then a quoted string runs to its closing quote or the line's end, and a bare value to the
|
|
51
|
+
# next delimiter. A bare value never opens a bracket.
|
|
52
|
+
SCHEME = /(?:bearer|basic|digest|token)[ \t]+/i
|
|
53
|
+
BARE_END = /[\s&"'<>,;)\]}]/
|
|
54
|
+
BARE_EXCLUDED = "[{(".bytes.freeze
|
|
55
|
+
QUOTES = "\"'".bytes.freeze
|
|
56
|
+
BACKSLASH = "\\".ord
|
|
49
57
|
JSON_DOCUMENT = /\A\s*[\[{]/
|
|
50
58
|
QUERY_CONTEXT = "?&#".bytes.freeze
|
|
51
59
|
|
|
@@ -96,12 +104,11 @@ module Foam
|
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
# Masks the value when its key or header name is sensitive. Otherwise url.query is read as a
|
|
99
|
-
# query
|
|
107
|
+
# query and the rest is redacted by content.
|
|
100
108
|
def redact_attribute(key, value)
|
|
101
109
|
kind = mask_kind(key) || header_mask_kind(key)
|
|
102
110
|
return mask_elements(kind, value) if kind
|
|
103
111
|
return redact_query_pairs(value) if key.downcase == OpenTelemetry::SemConv::URL::URL_QUERY && value.is_a?(String)
|
|
104
|
-
return value if BODY_KEYS.include?(key)
|
|
105
112
|
|
|
106
113
|
redact_value(value)
|
|
107
114
|
rescue
|
|
@@ -127,6 +134,21 @@ module Foam
|
|
|
127
134
|
QUERY_KEYS.include?(normalize_key(key))
|
|
128
135
|
end
|
|
129
136
|
|
|
137
|
+
# The position after the closing quote of the string whose content starts at start, or nil
|
|
138
|
+
# when none comes before limit. A quote behind an odd run of backslashes is escaped. The
|
|
139
|
+
# search is by position, so the string's length costs nothing.
|
|
140
|
+
def self.closing_quote(text, start, quote, limit)
|
|
141
|
+
from = start
|
|
142
|
+
while (found = text.byteindex(quote.chr, from)) && found < limit
|
|
143
|
+
backslashes = 0
|
|
144
|
+
backslashes += 1 while found - backslashes > start && text.getbyte(found - backslashes - 1) == BACKSLASH
|
|
145
|
+
return found + 1 if backslashes.even?
|
|
146
|
+
|
|
147
|
+
from = found + 1
|
|
148
|
+
end
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
130
152
|
# [REDACTED] for a built-in or pii key; a secret key keeps the last four characters of a String,
|
|
131
153
|
# Symbol, or number of 12 or more characters, so a value stays recognizable without being usable.
|
|
132
154
|
# nil stays nil: there is no value to protect.
|
|
@@ -179,10 +201,18 @@ module Foam
|
|
|
179
201
|
Redactor.normalize_key(key)
|
|
180
202
|
end
|
|
181
203
|
|
|
182
|
-
# Whether the key is on the built-in list or contains one of its segments as
|
|
204
|
+
# Whether the key is on the built-in list or contains one of its segments as whole words:
|
|
183
205
|
# legacy_api_key_2 contains api_key, tokenizer does not contain token.
|
|
184
206
|
def floor?(normalized)
|
|
185
|
-
FLOOR_KEYS.include?(normalized)
|
|
207
|
+
return true if FLOOR_KEYS.include?(normalized)
|
|
208
|
+
|
|
209
|
+
previous = nil
|
|
210
|
+
normalized.split("_").each do |word|
|
|
211
|
+
return true if FLOOR_WORDS.include?(word) || FLOOR_PAIRS[previous]&.include?(word)
|
|
212
|
+
|
|
213
|
+
previous = word
|
|
214
|
+
end
|
|
215
|
+
false
|
|
186
216
|
end
|
|
187
217
|
|
|
188
218
|
def redact_node(value, depth, ancestors)
|
|
@@ -254,14 +284,33 @@ module Foam
|
|
|
254
284
|
next unless masked_key?(scanner, bytes)
|
|
255
285
|
|
|
256
286
|
start = scanner.pos
|
|
257
|
-
|
|
287
|
+
scanner.scan(SCHEME)
|
|
288
|
+
stop, quote, closed = value_end(bytes, scanner.pos)
|
|
289
|
+
next unless stop
|
|
290
|
+
|
|
258
291
|
out ||= String.new(encoding: Encoding::BINARY)
|
|
259
|
-
out << bytes.byteslice(last...start) << mask_value(
|
|
260
|
-
last = scanner.pos
|
|
292
|
+
out << bytes.byteslice(last...start) << mask_value(quote, closed)
|
|
293
|
+
last = scanner.pos = stop
|
|
261
294
|
end
|
|
262
295
|
out ? out << bytes.byteslice(last..) : bytes
|
|
263
296
|
end
|
|
264
297
|
|
|
298
|
+
# Where the value at position ends, with its quote byte and whether that quote was closed;
|
|
299
|
+
# nil when no value starts there. A quote left open runs to the end of its line.
|
|
300
|
+
def value_end(bytes, position)
|
|
301
|
+
byte = bytes.getbyte(position)
|
|
302
|
+
return if byte.nil? || BARE_EXCLUDED.include?(byte)
|
|
303
|
+
|
|
304
|
+
if QUOTES.include?(byte)
|
|
305
|
+
limit = bytes.byteindex("\n", position) || bytes.bytesize
|
|
306
|
+
closed = Redactor.closing_quote(bytes, position + 1, byte, limit)
|
|
307
|
+
[closed || limit, byte, !closed.nil?]
|
|
308
|
+
else
|
|
309
|
+
stop = bytes.byteindex(BARE_END, position) || bytes.bytesize
|
|
310
|
+
[stop, nil, false] if stop > position
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
265
314
|
# A key is masked on its name, or on a query-only name when it follows ?, &, or # with a bare =
|
|
266
315
|
# after it: code, sig and the like are credentials only in a query string.
|
|
267
316
|
def masked_key?(scanner, bytes)
|
|
@@ -275,12 +324,10 @@ module Foam
|
|
|
275
324
|
end
|
|
276
325
|
|
|
277
326
|
# The mask in the quotes the value came with; a quote left open stays open.
|
|
278
|
-
def mask_value(
|
|
279
|
-
|
|
280
|
-
return Constants::REDACTED_VALUE unless quote == '"' || quote == "'"
|
|
327
|
+
def mask_value(quote, closed)
|
|
328
|
+
return Constants::REDACTED_VALUE unless quote
|
|
281
329
|
|
|
282
|
-
|
|
283
|
-
"#{quote}#{Constants::REDACTED_VALUE}#{quote if closed}"
|
|
330
|
+
"#{quote.chr}#{Constants::REDACTED_VALUE}#{quote.chr if closed}"
|
|
284
331
|
end
|
|
285
332
|
|
|
286
333
|
def redact_pair(pair)
|
data/lib/foam/otel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: foam-otel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.0.0.alpha.
|
|
4
|
+
version: 3.0.0.alpha.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Foam
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: opentelemetry-exporter-otlp
|