foam-otel 3.0.0.alpha.3 → 3.0.0.alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec08cbf830c20cd83d38b22e77be69cd7ccabb17cc11742a09ce2c1a13c8f668
4
- data.tar.gz: ece6b5b1553b38efc87a6b9d73219230a54a7ebd29d32d74dd05cd202882ab56
3
+ metadata.gz: 34ec502fdc38ac38099bba65f1fc37c91f1edb79476bff598c6a0dda7245fcf0
4
+ data.tar.gz: 9596d8c1d746b66103b87587f5c52006e024d0498d93816912ed916014e563e4
5
5
  SHA512:
6
- metadata.gz: 350906e9ca2b1542cc1c3f7fe3ca8f3f4c15cfc190e7c50c2c793454dbd512b16a0c54d3b30c7ef78f5c3698ee53dae61d516d9f73b97564cfb6552913ba2c53
7
- data.tar.gz: 58d4f62efe472391622739bb85bb86fc554fa92d4d3942f9317bedfaf3542d8697bf80ba86a55bb3acdce62cbbdc36a722c7a47378256ba959b0c4f690ac22ac
6
+ metadata.gz: 85207e2233f8a986bb874f68215960f20216e010978e5d1355808ec2ed6bfb44f19c9b8f8947cc3164cca65864326dab8b5ef95fb8ef6c37a1150fe06923ae2a
7
+ data.tar.gz: 3a3bc463b06b32e1d1bd4b6c439e5f8a92b031859301f8681e78a32e4e1db655ef73d4439a0755e336abc9fc1b068bca48049172046732da4adb514eb12c00cb
data/README.md CHANGED
@@ -29,7 +29,20 @@ Store the ingest token as `FOAM_OTEL_TOKEN` and pass `ENV["FOAM_OTEL_TOKEN"]` to
29
29
 
30
30
  ### Rails
31
31
 
32
- TODO(jhartquist): Rails setup. The Railtie that adds Foam's Rack middleware is not written, so a Rails app is not covered by header and body capture on server spans until it is.
32
+ Create `config/initializers/foam.rb`:
33
+
34
+ ```ruby
35
+ Foam.init(
36
+ name: "checkout-api",
37
+ environment: Rails.env,
38
+ enabled: true,
39
+ token: ENV["FOAM_OTEL_TOKEN"]
40
+ )
41
+ ```
42
+
43
+ During boot, `init` adds Foam's Rack middleware to the app directly after the OpenTelemetry Rack middleware, so server spans get the same header and body capture as under plain Rack. `init` must run before the app initializes: an initializer file or `config/application.rb`. An `init` from `after_initialize` or later warns on stderr and adds no middleware.
44
+
45
+ Nothing else changes; `config.ru` stays as generated. A `RUBYOPT` preload runs before Rails loads, so it installs no Rails instrumentation and adds no middleware.
33
46
 
34
47
  ### Plain Ruby and Rack
35
48
 
@@ -89,6 +102,8 @@ Every method lives on the `Foam` module. Other than the ingest factories, none o
89
102
 
90
103
  Sets up tracing, metrics, logs, automatic instrumentation, W3C `tracecontext` and `baggage` propagation, and OTLP export to Foam.
91
104
 
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 set 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 Foam claimed.
106
+
92
107
  Call this once. Use it when Foam should run OpenTelemetry for the process.
93
108
 
94
109
  ```ruby
@@ -133,7 +148,7 @@ Foam.init(
133
148
  - `version` - the service version, sent as `service.version` when given.
134
149
  - `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.
135
150
  - `additional_span_processors`, `additional_log_record_processors`, `additional_metric_readers` - the app's own OpenTelemetry components, added to Foam's providers after Foam's own exporters.
136
- - `additional_resource_attributes` - attributes merged into the resource of every signal. `service.name`, `service.version`, and `deployment.environment.name` come from the options above; a value for one of them here is reported on stderr and ignored.
151
+ - `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.
137
152
  - `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.
138
153
  - `ignored_outbound_hosts` - skips tracing of outbound HTTP requests to these hostnames. The endpoint host is always skipped. Use it for hosts whose traffic would drown the traces, such as a sidecar health check on `localhost` or another vendor's OTLP ingest host. Hosts are compared as written; `localhost` and `127.0.0.1` are different hosts. A request to a skipped host still carries a `traceparent` marked not sampled, so an OpenTelemetry service behind it records nothing for that request.
139
154
  - `disable_log_sending` - `true` stops shipping the app's `Logger` lines to Foam. `Foam.log` is unaffected. See Loggers.
@@ -143,11 +158,19 @@ Foam.init(
143
158
 
144
159
  TODO(jhartquist): `propagation_targets`, an allowlist of the hosts that receive `traceparent`, `tracestate`, and `baggage`. Spans stay on for every other host; only the headers are withheld. Today `ignored_outbound_hosts` is the only lever and it drops the spans too. The client patches see the host and can strip the headers after the instrumentation injects them. The endpoint host stays fully ignored.
145
160
 
146
- Every signal carries a resource with the service identity from the options above, `telemetry.distro.name` and `telemetry.distro.version` naming this gem, `host.name`, `host.arch`, `host.id` when it can be read, `os.type`, and `os.version`, and the process and SDK attributes the OpenTelemetry SDK adds. `OTEL_RESOURCE_ATTRIBUTES` can add further attributes. It cannot override any of the attributes above, and `OTEL_SERVICE_NAME` cannot override `name`.
161
+ Every signal carries a resource with these attributes:
162
+
163
+ - The service identity from the options above: `service.name`, `deployment.environment.name`, and `service.version` when given.
164
+ - `service.instance.id`, a UUID made for the process at `init`.
165
+ - `telemetry.distro.name` and `telemetry.distro.version`, naming this gem.
166
+ - `host.name`, `host.arch`, `host.id` when it can be read, `os.type`, and `os.version`.
167
+ - The process and SDK attributes the OpenTelemetry SDK adds.
168
+
169
+ 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`.
147
170
 
148
171
  `init` never raises. A failure or an option holding an invalid value is reported on stderr with a `[foam-otel]` prefix and leaves `state` uninitialized.
149
172
 
150
- `init` also reports its outcome to Foam: one log record with the message and the `state`, sent straight to the endpoint with the token. It is sent in the background, never blocks the app, and is dropped when it cannot be delivered. Nothing is sent without a token.
173
+ `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.
151
174
 
152
175
  ### `set_endpoint(url)`
153
176
 
@@ -229,7 +252,7 @@ OpenTelemetry::Context.with_current(context) do
229
252
  end
230
253
  ```
231
254
 
232
- Foam's helpers always speak W3C, whichever propagator the SDK configured globally (see `OTEL_PROPAGATORS`).
255
+ `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.
233
256
 
234
257
  ### `set_baggage(key, value)`
235
258
 
@@ -287,9 +310,9 @@ rescue PaymentError => e
287
310
  end
288
311
  ```
289
312
 
290
- The span gets an `exception` event with the type, message, and stack trace, plus any `attributes`, and its status becomes error with the exception's message. The active span is used whichever SDK started it. Without an active span nothing is recorded.
313
+ The span gets an `exception` event with the type, message, and stack trace, plus any `attributes`, and its status becomes error with the exception's message. A value that is not an `Exception` is recorded by its string form as the message, with no type or stack trace. The active span is used whichever SDK started it. Without an active span nothing is recorded.
291
314
 
292
- ### `flush(timeout: 30)`
315
+ ### `flush(timeout: 10)`
293
316
 
294
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.
295
318
 
@@ -299,14 +322,16 @@ Foam.flush(timeout: 5)
299
322
 
300
323
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
301
324
 
302
- ### `shutdown(timeout: 30)`
325
+ ### `shutdown(timeout: 10)`
303
326
 
304
- Flushes and shuts down Foam's providers, including the one behind `log` when an ingest component created it. Both `init` and the ingest log factory register it with `at_exit`, so most apps never call it. Call it yourself before a process is killed without a normal exit.
327
+ Flushes and shuts down Foam's providers, including the one behind `log` when an ingest component created it, and 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.
305
328
 
306
329
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
307
330
 
308
331
  `at_exit` covers a normal exit, an unhandled exception, and a signal the process handles by exiting. `exit!`, `KILL`, and a crash lose the last batch, and so does a batch that outlasts the timeout. An exception outside any span is invisible unless the app logs it.
309
332
 
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.
334
+
310
335
  ### `state`
311
336
 
312
337
  Returns whether Foam initialized, which instrumentations registered, which signals have an export path, and the identity `init` was given.
@@ -342,7 +367,7 @@ Each `signals` value names the source of the Foam export path for that signal:
342
367
  - `:local` - logs only: another SDK owns the global provider and Foam keeps its own so `log` still delivers.
343
368
  - `:none` - no Foam export path.
344
369
 
345
- `:global` is checked against the live OpenTelemetry global at each call. When another SDK takes a slot after `init`, that signal reports `:none` (`:local` for logs) and a warning is written to stderr once. Only the slot is affected: the Foam helpers keep their own references and keep working. `baggage` is `:global` or `:none`. `profile` is not supported and always reports `:none`.
370
+ A slot another SDK held before `init` stays with it and reports `:none` (`:local` for logs) from the start. Foam builds no provider for that signal, so the metric helpers and `set_baggage` do nothing while `log` still delivers. `:global` is checked against the live OpenTelemetry global on each call, so a slot another SDK takes after `init` reports the same way; in that case only the slot changes hands, and the helpers keep using 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`.
346
371
 
347
372
  ## Custom spans and other OpenTelemetry APIs
348
373
 
@@ -360,7 +385,7 @@ There is no option to record other headers.
360
385
 
361
386
  ### Header capture
362
387
 
363
- Server spans get the headers from `Foam::Rack::Middleware`, placed after the OpenTelemetry Rack middleware. A Rails app is not covered yet.
388
+ Server spans get the headers from `Foam::Rack::Middleware`, placed after the OpenTelemetry Rack middleware (added by `init` in Rails apps).
364
389
 
365
390
  Client spans get the headers from Foam's hooks on `Net::HTTP`, Excon, HTTPClient, and Faraday, applied unless `network_capture` is `:off`. The `Net::HTTP` hook is always installed; the others install only when their gem is loaded before `init`, and Faraday's covers connections built after `init`. Faraday is covered on the Faraday span whatever the adapter. `http`, `httpx`, `ethon`, and `restclient` are not covered.
366
391
 
@@ -380,7 +405,7 @@ In `:advanced` mode each captured body arrives as `foam.http.body.chunk` log rec
380
405
 
381
406
  ### Rack bodies
382
407
 
383
- Server bodies need `Foam::Rack::Middleware` after the OpenTelemetry Rack middleware, as shown under "Plain Ruby and Rack". A Rails app is not covered yet.
408
+ Server bodies need `Foam::Rack::Middleware` after the OpenTelemetry Rack middleware, as shown under "Plain Ruby and Rack" (added by `init` in Rails apps).
384
409
 
385
410
  - The request body is captured whether or not the app reads it when the input is rewindable, as it is under Puma, which buffers request bodies. Otherwise what the app reads is captured.
386
411
  - 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.
@@ -390,13 +415,14 @@ Server bodies need `Foam::Rack::Middleware` after the OpenTelemetry Rack middlew
390
415
 
391
416
  Client bodies are captured in `:advanced` by the same hooks that set the headers.
392
417
 
393
- - `Net::HTTP`: a request sent through `body_stream` and a response read in a block or into an IO are not captured.
418
+ - `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.
394
419
  - 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.
395
420
  - Excon: a request sent with `request_block` and a response received with `response_block` are not captured.
396
421
  - HTTPClient: a request whose body is an IO or multipart parts is not captured. A response read in a block is captured.
397
- - RestClient, `http`, `httpx`, and `ethon`: no bodies, since they get no header capture either.
422
+ - RestClient and HTTParty: captured on the `Net::HTTP` span beneath them.
423
+ - `http`, `httpx`, and `ethon`: no bodies, since they get no header capture either.
398
424
 
399
- TODO(jhartquist): capture streamed bodies: a request sent from an IO and a response the client streams to the app.
425
+ 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.
400
426
 
401
427
  ## Loggers
402
428
 
@@ -414,6 +440,12 @@ If another SDK owns logs, set `disable_log_sending: true` so records do not arri
414
440
 
415
441
  Secrets in a log line are masked before export. See Redaction.
416
442
 
443
+ ## AWS Lambda
444
+
445
+ The invocation span carries `faas.trigger` for API Gateway, ALB, SNS, SQS, S3, DynamoDB streams, and EventBridge schedule events.
446
+
447
+ Traces, metrics, and logs are flushed before the handler returns (including failing invocations).
448
+
417
449
  ## Pre-forking servers and threads
418
450
 
419
451
  TODO(jhartquist): pre-forking servers (Puma in cluster mode, Unicorn, Pitchfork, Resque): whether to call `init` before or after the fork, and what the SDK's batch processors and the metric reader do in the child. Foam registers no fork hook today.
@@ -431,7 +463,13 @@ end
431
463
 
432
464
  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, with their framing kept. When masking a body fails, the whole body is replaced with `********`.
433
465
 
434
- Every other string is scanned as text, a log body included. A string that is a whole JSON document is masked by key at every depth, so a list or object under a sensitive key becomes one mask. Any other string has the value after each sensitive key masked, whether the key is quoted as JSON writes it or bare before `=`, `:`, or `=>`, and whether it sits in a query string, a header line, or prose. The value ends at the next delimiter, so the rest of the line is kept, a quoted URL's closing quote included. Such a string is scanned up to 1 MiB; the rest collapses into one mask. `url.query` is masked pair by pair, and names such as `code` and `sig` count as sensitive only inside a query string. Masking is value-only: the key and the surrounding text stay.
466
+ Every other string is scanned as text, log bodies included.
467
+
468
+ - 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.
469
+ - 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, and a quoted URL keeps its closing quote.
470
+ - `url.query` is masked pair by pair. Names such as `code` and `sig` are sensitive only inside a query string.
471
+
472
+ 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.
435
473
 
436
474
  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. That tail is kept only where the value is read whole under its key: an attribute, a hash or array, or a whole JSON document. A value found by scanning text or a `url.query` pair becomes `[REDACTED]` whatever its key.
437
475
 
@@ -528,7 +566,7 @@ The `bunny`, `rdkafka`, `ruby_kafka`, `racecar`, `sidekiq`, `resque`, `que`, `de
528
566
  OpenTelemetry packages outside the instrumentation bundle that Foam does not wire yet.
529
567
 
530
568
  - Resource detectors. Foam's resource carries the service identity, the distro, host, and OS attributes, and the process and SDK attributes the OpenTelemetry SDK adds; `OTEL_RESOURCE_ATTRIBUTES` adds to it. The contrib detectors, `opentelemetry-resource-detector-aws`, `-azure`, `-container`, and `-google_cloud_platform`, are not run. The cloud ones probe metadata endpoints, so they belong off the `init` critical path.
531
- - Propagators. `init` keeps the SDK's default propagators, W3C `tracecontext` and `baggage`. The SDK honors `OTEL_PROPAGATORS` for `b3`, `b3multi`, `jaeger`, `xray`, and `ottrace` when the propagator gem is present, but the gems are not dependencies of `foam-otel`, and `inject_trace_context` and `extract_trace_context` speak W3C whatever is configured.
569
+ - Propagators. `init` registers W3C `tracecontext` and `baggage` when the propagator slot is free and does not read `OTEL_PROPAGATORS`. The contrib propagator gems (`b3`, `b3multi`, `jaeger`, `xray`, `ottrace`) are not dependencies of `foam-otel`; an app that needs one registers it before `init`. `inject_trace_context` and `extract_trace_context` always use the W3C headers.
532
570
  - Samplers. `sample_rate` builds a parent-based ratio sampler and `OTEL_TRACES_SAMPLER` is not consulted. `opentelemetry-sampler-xray` is the only contrib sampler; there is no remote sampler for Jaeger.
533
571
 
534
572
  ## TODO(jhartquist): Safely
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Foam's additions to the aws_lambda instrumentation, prepended on its Wrap module by init
7
+ # after installation:
8
+ # 1. Fills in faas.trigger from the event's shape for the AWS event sources the
9
+ # instrumentation does not derive it for.
10
+ # 2. Flushes Foam's logger provider where the wrapper flushes the tracer and meter providers,
11
+ # so an invocation's log records leave before the handler returns.
12
+ module AwsLambda
13
+ INSTRUMENTATION = "OpenTelemetry::Instrumentation::AwsLambda"
14
+ TRIGGER = "faas.trigger"
15
+
16
+ # A record's event source -> the semantic conventions' trigger for it.
17
+ RECORD_TRIGGERS = {
18
+ "aws:sns" => "pubsub",
19
+ "aws:sqs" => "pubsub",
20
+ "aws:s3" => "datasource",
21
+ "aws:dynamodb" => "datasource"
22
+ }.freeze
23
+
24
+ def self.install
25
+ return unless Instrumentations.instance(INSTRUMENTATION).installed?
26
+
27
+ ::OpenTelemetry::Instrumentation::AwsLambda::Wrap.prepend(Wrap)
28
+ end
29
+
30
+ # The trigger the event's shape names, or nil for a shape this does not know. SNS spells
31
+ # its record key EventSource; the other record sources spell it eventSource.
32
+ def self.trigger(event)
33
+ record = event["Records"]&.first
34
+ if record
35
+ RECORD_TRIGGERS[record["eventSource"] || record["EventSource"]]
36
+ elsif event["source"] == "aws.events" && event["detail-type"] == "Scheduled Event"
37
+ "timer"
38
+ end
39
+ end
40
+
41
+ module Wrap
42
+ def wrap_lambda(
43
+ event:, context:, handler:,
44
+ flush_timeout: ::OpenTelemetry::Instrumentation::AwsLambda::Wrap::DEFAULT_FLUSH_TIMEOUT
45
+ )
46
+ super
47
+ ensure
48
+ # Runs for an error too, so the records of a failed invocation leave before the error
49
+ # reaches the runtime. The timeout is the one the wrapper gives the other providers.
50
+ Safely.call("aws_lambda log flush") do
51
+ State.logger_provider&.force_flush(timeout: flush_timeout)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def otel_attributes(event, context)
58
+ attributes = super
59
+ return attributes if attributes.key?(TRIGGER)
60
+
61
+ trigger = Safely.call("faas.trigger") { AwsLambda.trigger(event) }
62
+ attributes[TRIGGER] = trigger if trigger
63
+ attributes
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -6,10 +6,9 @@ module Foam
6
6
  module Clients
7
7
  # Sets the safe headers on Net::HTTP client spans and, in advanced network capture,
8
8
  # captures their request and response bodies. Prepended on Net::HTTP by init unless network
9
- # capture is off. Captures a body only when Net::HTTP holds it as a String, so these are
10
- # not captured:
11
- # - a request sent through body_stream
12
- # - a response read in a block or into an IO
9
+ # capture is off. A request body is captured whether set as a String or streamed through
10
+ # body_stream. A response body is captured only when Net::HTTP reads it into a String; a
11
+ # response the app reads itself, in a block or into an IO, is not.
13
12
  module NetHttp
14
13
  private
15
14
 
@@ -35,6 +34,7 @@ module Foam
35
34
  yield response if block_given?
36
35
  end
37
36
  ensure
37
+ req.body_stream = req.body_stream.stream if req.body_stream.is_a?(ReadTee)
38
38
  request&.finalize(complete: !res.nil?)
39
39
  Safely.call("request header capture") do
40
40
  BodyCapture.set_header_attributes(span, :request, body_headers(req))
@@ -44,15 +44,24 @@ module Foam
44
44
  res
45
45
  end
46
46
 
47
- # Only a String body is captured: a stream or multipart data goes straight to the socket.
47
+ # A String body is observed in one piece. A body_stream is wrapped in a ReadTee, which
48
+ # observes each chunk as Net::HTTP copies it to the socket; transport_request unwraps it
49
+ # afterwards. Multipart form data is not captured.
48
50
  def capture_request(req, span, body_redactor)
49
51
  body = req.body
50
- return unless body.is_a?(String)
52
+ stream = req.body_stream
53
+ return unless body.is_a?(String) || stream.respond_to?(:read)
51
54
 
52
55
  capture = BodyCapture.start(span, body_redactor, side: :client, direction: :request) do
53
56
  body_headers(req)
54
57
  end
55
- capture&.observe(body)
58
+ return unless capture
59
+
60
+ if body
61
+ capture.observe(body)
62
+ else
63
+ req.body_stream = ReadTee.new(stream, capture)
64
+ end
56
65
  capture
57
66
  end
58
67
 
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ module Clients
7
+ # Wraps a request body_stream so the bytes Net::HTTP sends can be captured without buffering
8
+ # them. Net::HTTP copies the stream to the socket with IO.copy_stream, so the read is the
9
+ # only place to see them.
10
+ class ReadTee
11
+ # The wrapped stream, for putting it back on the request once it is sent.
12
+ attr_reader :stream
13
+
14
+ def initialize(stream, capture)
15
+ @stream = stream
16
+ @capture = capture
17
+ end
18
+
19
+ def read(*args)
20
+ bytes = @stream.read(*args)
21
+ @capture.observe(bytes) if bytes
22
+ bytes
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -52,15 +52,24 @@ module Foam
52
52
  # The log body of every body chunk record; the chunk's text goes in its attributes.
53
53
  BODY_CHUNK_EVENT_BODY = "HTTP body chunk"
54
54
 
55
- # The mask for a value. A secret key's value read whole gets FULL_MASK and a tail instead.
55
+ # Replaces a redacted value.
56
56
  REDACTED_VALUE = "[REDACTED]"
57
57
 
58
- # The mask for a value under a secret key that has no tail to keep, and for one redaction failed on.
58
+ # Replaces a secret: all of it when short or not text, otherwise all but its last four
59
+ # characters. Also stands in for a value whose redaction failed.
59
60
  FULL_MASK = "********"
60
61
 
61
- # The length from which a value under a secret key keeps its last four characters.
62
+ # Secrets at least this long keep their last four characters unmasked.
62
63
  TAIL_MASK_THRESHOLD = 12
63
64
 
65
+ # Finished spans Foam's span processor holds before dropping the oldest. The SDK default
66
+ # of 2048 loses spans when a loop finishes more than that before the export thread runs.
67
+ SPAN_QUEUE_SIZE = 4096
68
+
69
+ # Seconds flush and shutdown wait by default. It is the OTLP exporters' own request timeout,
70
+ # and it keeps the at_exit shutdown inside a 30 second deploy grace period.
71
+ LIFECYCLE_TIMEOUT = 10
72
+
64
73
  module_function
65
74
 
66
75
  def bearer_headers(token)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module Internal
6
+ # Runs State.after_fork in every child this process forks, by wrapping Process._fork, the
7
+ # method fork and Process.fork call. Preforking servers init Foam in the master, and a
8
+ # forked child gets the parent's memory but none of its threads, so the periodic metric
9
+ # reader's export thread is gone and the child's metrics would never leave it, and the
10
+ # providers' resource still names the parent process.
11
+ module ForkHooks
12
+ def _fork
13
+ parent = Process.pid
14
+ super.tap { State.after_fork unless Process.pid == parent }
15
+ end
16
+
17
+ def self.attach
18
+ Process.singleton_class.prepend(self)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -18,6 +18,10 @@ module Foam
18
18
  # Every outcome is reported with the same identity, including the ones that stop early.
19
19
  identity = options.slice(:name, :environment, :token)
20
20
 
21
+ # Recorded before the checks, so state names the app after a disabled or failed init too.
22
+ # The token stays out of state on purpose; it is read by health checks and tests.
23
+ State.record_params(options.slice(:name, :environment))
24
+
21
25
  # Teams may turn the SDK off in specific environments.
22
26
  unless options[:enabled]
23
27
  Report.call(severity: :info, message: "SDK disabled", **identity)
@@ -63,27 +67,39 @@ module Foam
63
67
  State.take_provider(:logger)&.shutdown(timeout: nil)
64
68
 
65
69
  redactor = Redactor.from_option(options[:redact])
66
- configure(instrumentation_config: instrumentation_config, redactor: redactor, **options)
67
-
68
- # The client patches set the safe headers on every client span and, in advanced mode,
69
- # capture bodies too. Bodies are redacted by the same rules as attributes, so only
70
- # advanced builds a body redactor; its presence is what switches body capture on.
71
- body_redactor = BodyRedactor.new(redactor) if options[:network_capture] == :advanced
72
- install_client_patches unless options[:network_capture] == :off
73
-
74
- # Recorded last so a failed init leaves the state uninitialized.
75
- # The token stays out of state on purpose; it is read by health checks and tests.
76
- State.record_init(
77
- params: options.slice(:name, :environment),
78
- tracer_provider: OpenTelemetry.tracer_provider,
79
- meter_provider: OpenTelemetry.meter_provider,
80
- logger_provider: OpenTelemetry.logger_provider,
81
- propagator: OpenTelemetry.propagation,
82
- instrumentations: Instrumentations.installed(instrumentation_config),
83
- instrumentation_config: instrumentation_config,
84
- body_redactor: body_redactor,
85
- network_capture: options[:network_capture]
70
+ providers, claimed_slots = configure(
71
+ instrumentation_config: instrumentation_config, redactor: redactor, **options
86
72
  )
73
+ begin
74
+ ForkHooks.attach
75
+
76
+ # The client patches set the safe headers on every client span and, in advanced mode,
77
+ # capture bodies too. Bodies are redacted by the same rules as attributes, so only
78
+ # advanced builds a body redactor; its presence is what switches body capture on.
79
+ body_redactor = BodyRedactor.new(redactor) if options[:network_capture] == :advanced
80
+ install_client_patches unless options[:network_capture] == :off
81
+
82
+ # Adds faas.trigger for the event sources the aws_lambda instrumentation skips, and flushes
83
+ # Foam's logger provider where it flushes the tracer and meter providers.
84
+ Safely.call("aws_lambda") { AwsLambda.install }
85
+
86
+ # A Rails app cannot add the middleware in config.ru the way a plain Rack app does, so
87
+ # Foam adds it through Rails itself.
88
+ Safely.call("rails middleware") { RailsMiddleware.install } unless options[:network_capture] == :off
89
+
90
+ # Recorded last so a failed init leaves the state uninitialized.
91
+ State.record_init(
92
+ providers: providers,
93
+ claimed_slots: claimed_slots,
94
+ instrumentations: Instrumentations.installed(instrumentation_config),
95
+ instrumentation_config: instrumentation_config,
96
+ body_redactor: body_redactor,
97
+ network_capture: options[:network_capture]
98
+ )
99
+ rescue
100
+ roll_back(providers, claimed_slots)
101
+ raise
102
+ end
87
103
 
88
104
  # Flush whatever the batch processors still hold before the process exits.
89
105
  at_exit { Foam.shutdown }
@@ -94,66 +110,111 @@ module Foam
94
110
  nil
95
111
  end
96
112
 
113
+ # Builds Foam's pipelines and claims the global slots the API still holds itself. Returns
114
+ # the providers built, by kind, and the slots claimed, by signal.
97
115
  def configure(
98
116
  token:, name:, environment:, sample_rate:, instrumentation_config:, additional_span_processors:,
99
117
  additional_metric_readers:, additional_log_record_processors:, additional_resource_attributes:,
100
118
  ignored_outbound_hosts:, redactor:, before_send:, version: nil, **
101
119
  )
102
- # Declared up front so the rescue below can stop whichever of these exist when something fails.
103
- span_processor = nil
104
- metric_reader = nil
105
- log_record_processor = nil
120
+ # Declared up front so the rescue below can stop whichever providers exist when
121
+ # something fails.
122
+ providers = {}
106
123
 
107
- sampler = IgnoredHosts.new(
108
- ignored_hosts(ignored_outbound_hosts),
109
- OpenTelemetry::SDK::Trace::Samplers.parent_based(
110
- root: OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(sample_rate)
111
- )
124
+ # The SDK's own switch, honored the way SDK.configure honors it: nothing is installed.
125
+ raise "OTEL_SDK_DISABLED is true" if ENV["OTEL_SDK_DISABLED"] == "true"
126
+
127
+ sampler = sampler(sample_rate, ignored_outbound_hosts)
128
+ resource = OpenTelemetry::SDK::Resources::Resource.default.merge(
129
+ Resource.create(name, environment, version, additional_resource_attributes)
112
130
  )
113
- resource = Resource.create(name, environment, version, additional_resource_attributes)
114
131
  before_send = BeforeSend.new(before_send)
115
132
 
116
- trace_exporter = Exporters::TraceExporter.new(
133
+ # A slot another SDK owns is left in place, and Foam builds no pipeline for that signal,
134
+ # since nothing would feed one. The logger provider is built either way, so log still
135
+ # delivers whoever holds the slot.
136
+ free = Slots::SIGNALS.select { |signal| Slots.free?(signal) }
137
+ (Slots::SIGNALS - free).each do |signal|
138
+ Diagnostics.warn("#{signal} global slot taken by #{Slots.live(signal).class}")
139
+ end
140
+ if free.include?(:traces)
141
+ exporter = Exporters::TraceExporter.new(
142
+ token: token, redactor: redactor, before_send: before_send
143
+ )
144
+ providers[:tracer] = tracer_provider(
145
+ resource, sampler, exporter, additional_span_processors
146
+ )
147
+ end
148
+ if free.include?(:metrics)
149
+ exporter = Exporters::MetricExporter.new(token: token, redactor: redactor)
150
+ providers[:meter] = meter_provider(resource, exporter, additional_metric_readers)
151
+ end
152
+ exporter = Exporters::LogExporter.new(
117
153
  token: token, redactor: redactor, before_send: before_send
118
154
  )
119
- metrics_exporter = Exporters::MetricExporter.new(token: token, redactor: redactor)
120
- logs_exporter = Exporters::LogExporter.new(
121
- token: token, redactor: redactor, before_send: before_send
155
+ providers[:logger] = logger_provider(
156
+ resource, exporter, additional_log_record_processors
122
157
  )
123
158
 
124
- span_processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(trace_exporter)
125
- metric_reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: metrics_exporter)
126
- log_record_processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(logs_exporter)
127
-
128
- # SDK.configure swallows its own exceptions; an unchanged slot is how we know it did not finish.
129
- previous_tracer_provider = OpenTelemetry.tracer_provider
130
-
131
- # configure installs the global providers and sets up the used instrumentations.
132
- OpenTelemetry::SDK.configure do |c|
133
- c.resource = resource
134
-
135
- c.add_span_processor(span_processor)
136
- additional_span_processors.each { |processor| c.add_span_processor(processor) }
159
+ claimed_slots = {
160
+ traces: providers[:tracer],
161
+ metrics: providers[:meter],
162
+ logs: (providers[:logger] if free.include?(:logs)),
163
+ baggage: (Propagation::PROPAGATOR if free.include?(:baggage))
164
+ }.compact
165
+ claimed_slots.each { |signal, object| Slots.claim(signal, object) }
166
+
167
+ # Installs the bundle and the caller's entries, those whose library is already loaded; the
168
+ # rest are skipped. After the slots, so each one binds its tracer to whoever holds traces.
169
+ OpenTelemetry::Instrumentation.registry.install(
170
+ instrumentation_config.keys, instrumentation_config
171
+ )
172
+ [providers, claimed_slots]
173
+ rescue
174
+ roll_back(providers, claimed_slots)
175
+ raise
176
+ end
137
177
 
138
- c.add_metric_reader(metric_reader)
139
- additional_metric_readers.each { |reader| c.add_metric_reader(reader) }
178
+ # Frees the slots and stops the providers a failed init built, so the next init starts clean.
179
+ def roll_back(providers, claimed_slots)
180
+ claimed_slots&.each { |signal, object| Slots.release(signal, object) }
181
+ Lifecycle.each(:shutdown, providers.values, timeout: nil)
182
+ end
140
183
 
141
- c.add_log_record_processor(log_record_processor)
142
- additional_log_record_processors.each { |processor| c.add_log_record_processor(processor) }
184
+ # Drops spans headed for an ignored host and samples the rest per trace at sample_rate.
185
+ def sampler(sample_rate, ignored_outbound_hosts)
186
+ IgnoredHosts.new(
187
+ ignored_hosts(ignored_outbound_hosts),
188
+ OpenTelemetry::SDK::Trace::Samplers.parent_based(
189
+ root: OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(sample_rate)
190
+ )
191
+ )
192
+ end
143
193
 
144
- # Installs the bundle and the caller's entries, those whose library is already loaded; the rest are skipped.
145
- instrumentation_config.each { |name, config| c.use(name, config) }
146
- end
194
+ def tracer_provider(resource, sampler, exporter, additional)
195
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
196
+ resource: resource, sampler: sampler
197
+ )
198
+ foam = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
199
+ exporter,
200
+ max_queue_size: Integer(ENV.fetch("OTEL_BSP_MAX_QUEUE_SIZE", Constants::SPAN_QUEUE_SIZE))
201
+ )
202
+ [foam, *additional].each { |processor| provider.add_span_processor(processor) }
203
+ provider
204
+ end
147
205
 
148
- if OpenTelemetry.tracer_provider.equal?(previous_tracer_provider)
149
- raise "no tracer provider was installed"
150
- end
206
+ def meter_provider(resource, exporter, additional)
207
+ provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
208
+ foam = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter)
209
+ [foam, *additional].each { |reader| provider.add_metric_reader(reader) }
210
+ provider
211
+ end
151
212
 
152
- # The configurator builds the provider with the SDK's default sampler and takes no other.
153
- OpenTelemetry.tracer_provider.sampler = sampler
154
- rescue
155
- Lifecycle.each(:shutdown, [span_processor, metric_reader, log_record_processor], timeout: nil)
156
- raise
213
+ def logger_provider(resource, exporter, additional)
214
+ provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
215
+ foam = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
216
+ [foam, *additional].each { |processor| provider.add_log_record_processor(processor) }
217
+ provider
157
218
  end
158
219
 
159
220
  # Net::HTTP is patched without a check, since foam-otel loads it for its own exporters; the