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

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: 1c7362be48b291f5a1c52aca227430f7ded8a223dfd289108f1bf97b3254b184
4
+ data.tar.gz: b876b8f462eccad1ce7f39e4ced08da44243755c0e615109a89891ef563078d6
5
5
  SHA512:
6
- metadata.gz: 350906e9ca2b1542cc1c3f7fe3ca8f3f4c15cfc190e7c50c2c793454dbd512b16a0c54d3b30c7ef78f5c3698ee53dae61d516d9f73b97564cfb6552913ba2c53
7
- data.tar.gz: 58d4f62efe472391622739bb85bb86fc554fa92d4d3942f9317bedfaf3542d8697bf80ba86a55bb3acdce62cbbdc36a722c7a47378256ba959b0c4f690ac22ac
6
+ metadata.gz: 157e5f359ca4d872a7106377d0315c767ff6fc624952408ce85383a8b8c2a4a2e0de62b97c15fc3d8fd261ea09b276b79c0b1b0c4205105a6ab44b45043ea5ab
7
+ data.tar.gz: c731cca87d8ccf41524ae89da935e8878efb45d3d7ae11c84b7172ddc490ac579da0cd363079fbce6ed8dd24750fa939fa65d30fc2c8611ec8f1b0ce341a2b9d
data/README.md CHANGED
@@ -89,6 +89,8 @@ Every method lives on the `Foam` module. Other than the ingest factories, none o
89
89
 
90
90
  Sets up tracing, metrics, logs, automatic instrumentation, W3C `tracecontext` and `baggage` propagation, and OTLP export to Foam.
91
91
 
92
+ `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.
93
+
92
94
  Call this once. Use it when Foam should run OpenTelemetry for the process.
93
95
 
94
96
  ```ruby
@@ -133,7 +135,7 @@ Foam.init(
133
135
  - `version` - the service version, sent as `service.version` when given.
134
136
  - `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
137
  - `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.
138
+ - `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
139
  - `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
140
  - `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
141
  - `disable_log_sending` - `true` stops shipping the app's `Logger` lines to Foam. `Foam.log` is unaffected. See Loggers.
@@ -143,11 +145,19 @@ Foam.init(
143
145
 
144
146
  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
147
 
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`.
148
+ Every signal carries a resource with these attributes:
149
+
150
+ - The service identity from the options above: `service.name`, `deployment.environment.name`, and `service.version` when given.
151
+ - `service.instance.id`, a UUID made for the process at `init`.
152
+ - `telemetry.distro.name` and `telemetry.distro.version`, naming this gem.
153
+ - `host.name`, `host.arch`, `host.id` when it can be read, `os.type`, and `os.version`.
154
+ - The process and SDK attributes the OpenTelemetry SDK adds.
155
+
156
+ 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
157
 
148
158
  `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
159
 
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.
160
+ `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
161
 
152
162
  ### `set_endpoint(url)`
153
163
 
@@ -229,7 +239,7 @@ OpenTelemetry::Context.with_current(context) do
229
239
  end
230
240
  ```
231
241
 
232
- Foam's helpers always speak W3C, whichever propagator the SDK configured globally (see `OTEL_PROPAGATORS`).
242
+ `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
243
 
234
244
  ### `set_baggage(key, value)`
235
245
 
@@ -287,9 +297,9 @@ rescue PaymentError => e
287
297
  end
288
298
  ```
289
299
 
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.
300
+ 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
301
 
292
- ### `flush(timeout: 30)`
302
+ ### `flush(timeout: 10)`
293
303
 
294
304
  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
305
 
@@ -299,14 +309,16 @@ Foam.flush(timeout: 5)
299
309
 
300
310
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
301
311
 
302
- ### `shutdown(timeout: 30)`
312
+ ### `shutdown(timeout: 10)`
303
313
 
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.
314
+ 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
315
 
306
316
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
307
317
 
308
318
  `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
319
 
320
+ 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.
321
+
310
322
  ### `state`
311
323
 
312
324
  Returns whether Foam initialized, which instrumentations registered, which signals have an export path, and the identity `init` was given.
@@ -342,7 +354,7 @@ Each `signals` value names the source of the Foam export path for that signal:
342
354
  - `:local` - logs only: another SDK owns the global provider and Foam keeps its own so `log` still delivers.
343
355
  - `:none` - no Foam export path.
344
356
 
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`.
357
+ 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
358
 
347
359
  ## Custom spans and other OpenTelemetry APIs
348
360
 
@@ -390,13 +402,14 @@ Server bodies need `Foam::Rack::Middleware` after the OpenTelemetry Rack middlew
390
402
 
391
403
  Client bodies are captured in `:advanced` by the same hooks that set the headers.
392
404
 
393
- - `Net::HTTP`: a request sent through `body_stream` and a response read in a block or into an IO are not captured.
405
+ - `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
406
  - 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
407
  - Excon: a request sent with `request_block` and a response received with `response_block` are not captured.
396
408
  - 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.
409
+ - RestClient and HTTParty: captured on the `Net::HTTP` span beneath them.
410
+ - `http`, `httpx`, and `ethon`: no bodies, since they get no header capture either.
398
411
 
399
- TODO(jhartquist): capture streamed bodies: a request sent from an IO and a response the client streams to the app.
412
+ 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
413
 
401
414
  ## Loggers
402
415
 
@@ -414,6 +427,12 @@ If another SDK owns logs, set `disable_log_sending: true` so records do not arri
414
427
 
415
428
  Secrets in a log line are masked before export. See Redaction.
416
429
 
430
+ ## AWS Lambda
431
+
432
+ The invocation span carries `faas.trigger` for API Gateway, ALB, SNS, SQS, S3, DynamoDB streams, and EventBridge schedule events.
433
+
434
+ Traces, metrics, and logs are flushed before the handler returns (including failing invocations).
435
+
417
436
  ## Pre-forking servers and threads
418
437
 
419
438
  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 +450,13 @@ end
431
450
 
432
451
  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
452
 
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.
453
+ Every other string is scanned as text, log bodies included.
454
+
455
+ - 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.
456
+ - 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.
457
+ - `url.query` is masked pair by pair. Names such as `code` and `sig` are sensitive only inside a query string.
458
+
459
+ 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
460
 
436
461
  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
462
 
@@ -528,7 +553,7 @@ The `bunny`, `rdkafka`, `ruby_kafka`, `racecar`, `sidekiq`, `resque`, `que`, `de
528
553
  OpenTelemetry packages outside the instrumentation bundle that Foam does not wire yet.
529
554
 
530
555
  - 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.
556
+ - 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
557
  - 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
558
 
534
559
  ## 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,35 @@ 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
+ # Recorded last so a failed init leaves the state uninitialized.
87
+ State.record_init(
88
+ providers: providers,
89
+ claimed_slots: claimed_slots,
90
+ instrumentations: Instrumentations.installed(instrumentation_config),
91
+ instrumentation_config: instrumentation_config,
92
+ body_redactor: body_redactor,
93
+ network_capture: options[:network_capture]
94
+ )
95
+ rescue
96
+ roll_back(providers, claimed_slots)
97
+ raise
98
+ end
87
99
 
88
100
  # Flush whatever the batch processors still hold before the process exits.
89
101
  at_exit { Foam.shutdown }
@@ -94,66 +106,111 @@ module Foam
94
106
  nil
95
107
  end
96
108
 
109
+ # Builds Foam's pipelines and claims the global slots the API still holds itself. Returns
110
+ # the providers built, by kind, and the slots claimed, by signal.
97
111
  def configure(
98
112
  token:, name:, environment:, sample_rate:, instrumentation_config:, additional_span_processors:,
99
113
  additional_metric_readers:, additional_log_record_processors:, additional_resource_attributes:,
100
114
  ignored_outbound_hosts:, redactor:, before_send:, version: nil, **
101
115
  )
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
116
+ # Declared up front so the rescue below can stop whichever providers exist when
117
+ # something fails.
118
+ providers = {}
106
119
 
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
- )
120
+ # The SDK's own switch, honored the way SDK.configure honors it: nothing is installed.
121
+ raise "OTEL_SDK_DISABLED is true" if ENV["OTEL_SDK_DISABLED"] == "true"
122
+
123
+ sampler = sampler(sample_rate, ignored_outbound_hosts)
124
+ resource = OpenTelemetry::SDK::Resources::Resource.default.merge(
125
+ Resource.create(name, environment, version, additional_resource_attributes)
112
126
  )
113
- resource = Resource.create(name, environment, version, additional_resource_attributes)
114
127
  before_send = BeforeSend.new(before_send)
115
128
 
116
- trace_exporter = Exporters::TraceExporter.new(
129
+ # A slot another SDK owns is left in place, and Foam builds no pipeline for that signal,
130
+ # since nothing would feed one. The logger provider is built either way, so log still
131
+ # delivers whoever holds the slot.
132
+ free = Slots::SIGNALS.select { |signal| Slots.free?(signal) }
133
+ (Slots::SIGNALS - free).each do |signal|
134
+ Diagnostics.warn("#{signal} global slot taken by #{Slots.live(signal).class}")
135
+ end
136
+ if free.include?(:traces)
137
+ exporter = Exporters::TraceExporter.new(
138
+ token: token, redactor: redactor, before_send: before_send
139
+ )
140
+ providers[:tracer] = tracer_provider(
141
+ resource, sampler, exporter, additional_span_processors
142
+ )
143
+ end
144
+ if free.include?(:metrics)
145
+ exporter = Exporters::MetricExporter.new(token: token, redactor: redactor)
146
+ providers[:meter] = meter_provider(resource, exporter, additional_metric_readers)
147
+ end
148
+ exporter = Exporters::LogExporter.new(
117
149
  token: token, redactor: redactor, before_send: before_send
118
150
  )
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
151
+ providers[:logger] = logger_provider(
152
+ resource, exporter, additional_log_record_processors
122
153
  )
123
154
 
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) }
155
+ claimed_slots = {
156
+ traces: providers[:tracer],
157
+ metrics: providers[:meter],
158
+ logs: (providers[:logger] if free.include?(:logs)),
159
+ baggage: (Propagation::PROPAGATOR if free.include?(:baggage))
160
+ }.compact
161
+ claimed_slots.each { |signal, object| Slots.claim(signal, object) }
162
+
163
+ # Installs the bundle and the caller's entries, those whose library is already loaded; the
164
+ # rest are skipped. After the slots, so each one binds its tracer to whoever holds traces.
165
+ OpenTelemetry::Instrumentation.registry.install(
166
+ instrumentation_config.keys, instrumentation_config
167
+ )
168
+ [providers, claimed_slots]
169
+ rescue
170
+ roll_back(providers, claimed_slots)
171
+ raise
172
+ end
137
173
 
138
- c.add_metric_reader(metric_reader)
139
- additional_metric_readers.each { |reader| c.add_metric_reader(reader) }
174
+ # Frees the slots and stops the providers a failed init built, so the next init starts clean.
175
+ def roll_back(providers, claimed_slots)
176
+ claimed_slots&.each { |signal, object| Slots.release(signal, object) }
177
+ Lifecycle.each(:shutdown, providers.values, timeout: nil)
178
+ end
140
179
 
141
- c.add_log_record_processor(log_record_processor)
142
- additional_log_record_processors.each { |processor| c.add_log_record_processor(processor) }
180
+ # Drops spans headed for an ignored host and samples the rest per trace at sample_rate.
181
+ def sampler(sample_rate, ignored_outbound_hosts)
182
+ IgnoredHosts.new(
183
+ ignored_hosts(ignored_outbound_hosts),
184
+ OpenTelemetry::SDK::Trace::Samplers.parent_based(
185
+ root: OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(sample_rate)
186
+ )
187
+ )
188
+ end
143
189
 
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
190
+ def tracer_provider(resource, sampler, exporter, additional)
191
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
192
+ resource: resource, sampler: sampler
193
+ )
194
+ foam = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
195
+ exporter,
196
+ max_queue_size: Integer(ENV.fetch("OTEL_BSP_MAX_QUEUE_SIZE", Constants::SPAN_QUEUE_SIZE))
197
+ )
198
+ [foam, *additional].each { |processor| provider.add_span_processor(processor) }
199
+ provider
200
+ end
147
201
 
148
- if OpenTelemetry.tracer_provider.equal?(previous_tracer_provider)
149
- raise "no tracer provider was installed"
150
- end
202
+ def meter_provider(resource, exporter, additional)
203
+ provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
204
+ foam = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter)
205
+ [foam, *additional].each { |reader| provider.add_metric_reader(reader) }
206
+ provider
207
+ end
151
208
 
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
209
+ def logger_provider(resource, exporter, additional)
210
+ provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
211
+ foam = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
212
+ [foam, *additional].each { |processor| provider.add_log_record_processor(processor) }
213
+ provider
157
214
  end
158
215
 
159
216
  # Net::HTTP is patched without a check, since foam-otel loads it for its own exporters; the
@@ -88,9 +88,9 @@ module Foam
88
88
 
89
89
  module_function
90
90
 
91
- # What init installs, name -> config: the bundle, with the logger bridge disabled when log sending
92
- # is, then the caller's entries, so an entry replaces Foam's config for the same name. One whose
93
- # instrumentation is not loaded stops init before anything is built.
91
+ # The instrumentation configs init installs, by name: the bundle, then the caller's entries,
92
+ # which override it. Disabling log sending disables the logger bridge. An entry naming an
93
+ # instrumentation that is not loaded fails init before anything is built.
94
94
  def config_map(additional, disable_log_sending: false)
95
95
  bundle = TABLE.keys.to_h { |name| [name, nil] }.merge(CONFIG)
96
96
  bundle[LOGGER] = {enabled: false} if disable_log_sending
@@ -19,13 +19,21 @@ module Foam
19
19
  each(:shutdown, providers, timeout: timeout)
20
20
  end
21
21
 
22
- # Providers, processors, and readers all flush and shut down the same way; each on its own so
23
- # one failure does not skip the others, and each with what is left of one shared timeout.
22
+ # Providers, processors, and readers all flush and shut down the same way; each in its own
23
+ # thread, so one that hangs or fails holds up neither the others nor the caller. The caller is
24
+ # back within one shared timeout; a thread still running then finishes on its own.
24
25
  def each(operation, components, timeout:)
25
26
  start = OpenTelemetry::Common::Utilities.timeout_timestamp
26
- components.compact.each do |component|
27
+ threads = components.compact.to_h do |component|
28
+ [component, Thread.new do
29
+ # The joining thread reports the failure, so the thread itself stays quiet.
30
+ Thread.current.report_on_exception = false
31
+ component.public_send(operation, timeout: timeout)
32
+ end]
33
+ end
34
+ threads.each do |component, thread|
27
35
  remaining = OpenTelemetry::Common::Utilities.maybe_timeout(timeout, start)
28
- code = component.public_send(operation, timeout: remaining)
36
+ code = thread.join(remaining) ? thread.value : TIMEOUT
29
37
 
30
38
  # Failures are logged by the exporter or provider themselves; a timeout is silent upstream.
31
39
  Diagnostics.warn("#{operation}: #{component.class.name} timed out") if code == TIMEOUT
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "socket"
6
+ require "timeout"
5
7
  require "opentelemetry/common"
6
8
 
7
9
  module Foam
@@ -12,24 +14,47 @@ module Foam
12
14
  # correlation, one request per record.
13
15
  module Otlp
14
16
  TIMEOUT = 2
17
+ # Seconds the caller spends resolving the endpoint host before a send.
18
+ RESOLVE_TIMEOUT = 1
19
+
20
+ @pending = []
21
+ @mutex = Mutex.new
15
22
 
16
23
  module_function
17
24
 
18
25
  # Sends in the background so the caller never waits on the network; delivery is best effort.
19
26
  def send_log(token:, resource_attributes:, scope_name:, severity:, body:)
20
27
  request = JSON.generate(log_request(resource_attributes, scope_name, severity, body))
21
- url = Endpoint.url(Constants::LOGS_PATH)
22
- Thread.new do
28
+ uri = URI(Endpoint.url(Constants::LOGS_PATH))
29
+ address = resolve(uri)
30
+ thread = Thread.new do
23
31
  # Untraced so the request never becomes a span once Net::HTTP is instrumented, and
24
32
  # marked as Foam's own so the client patches leave it alone.
25
33
  OwnRequest.mark do
26
- OpenTelemetry::Common::Utilities.untraced { post(url, request, token) }
34
+ OpenTelemetry::Common::Utilities.untraced { post(uri, address, request, token) }
27
35
  end
28
36
  rescue
29
37
  nil
30
38
  end
39
+ @mutex.synchronize do
40
+ @pending.select!(&:alive?)
41
+ @pending << thread
42
+ end
43
+ thread
44
+ end
45
+
46
+ # Waits for the sends still in flight, each with what is left of one shared timeout.
47
+ def drain(timeout: TIMEOUT)
48
+ start = OpenTelemetry::Common::Utilities.timeout_timestamp
49
+ @mutex.synchronize { @pending.dup }.each do |thread|
50
+ thread.join(OpenTelemetry::Common::Utilities.maybe_timeout(timeout, start))
51
+ end
52
+ nil
31
53
  end
32
54
 
55
+ # A process that exits right after init would otherwise lose its report.
56
+ at_exit { drain }
57
+
33
58
  def log_request(resource_attributes, scope_name, severity, body)
34
59
  {
35
60
  resourceLogs: [{
@@ -49,13 +74,25 @@ module Foam
49
74
  }
50
75
  end
51
76
 
52
- def post(url, request, token)
53
- uri = URI(url)
77
+ # Resolve on the calling thread. On Ruby 3.3, forking while a background thread is mid
78
+ # lookup leaves the child's resolver deadlocked.
79
+ def resolve(uri)
80
+ Timeout.timeout(RESOLVE_TIMEOUT) do
81
+ Addrinfo.getaddrinfo(uri.hostname, uri.port, nil, :STREAM).first.ip_address
82
+ end
83
+ rescue
84
+ nil
85
+ end
86
+
87
+ def post(uri, address, request, token)
54
88
  headers = Constants.bearer_headers(token).merge("Content-Type" => "application/json")
55
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
89
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", ipaddr: address,
56
90
  open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT) do |http|
57
91
  http.post(uri.path, request, headers)
58
92
  end
93
+ rescue SystemCallError, Net::OpenTimeout
94
+ # The address is the first Net::HTTP would try itself; a retry without it tries the rest.
95
+ address ? post(uri, nil, request, token) : raise
59
96
  end
60
97
  end
61
98
  end
@@ -40,6 +40,9 @@ module Foam
40
40
  # keeps its quotes or runs to the next delimiter; an auth scheme word like Bearer is part of it.
41
41
  JSON_KEY = /"((?:[^"\\\n]|\\.)*+)"(\s*(?:=>|:)\s*)/
42
42
  BARE_KEY = /(?<![\w.%])([A-Za-z_][\w.\-%]*+)([ \t]*(?:=>|={1,3}|:)[ \t]*)/
43
+ # Both key shapes in one pattern, so one pass visits every key in source order. Groups 1 and 2
44
+ # are a quoted key and its separator, groups 3 and 4 a bare one.
45
+ KEY = Regexp.union(JSON_KEY, BARE_KEY)
43
46
  QUOTED_VALUE = /"(?:[^"\\\n]|\\.)*+"?|'(?:[^'\\\n]|\\.)*+'?/
44
47
  BARE_VALUE = /(?![\[{(])[^\s&"'<>,;)\]}]+/
45
48
  VALUE = /(?:(?:bearer|basic|digest|token)[ \t]+)?(?:#{QUOTED_VALUE}|#{BARE_VALUE})/i
@@ -126,7 +129,9 @@ module Foam
126
129
 
127
130
  # [REDACTED] for a built-in or pii key; a secret key keeps the last four characters of a String,
128
131
  # Symbol, or number of 12 or more characters, so a value stays recognizable without being usable.
132
+ # nil stays nil: there is no value to protect.
129
133
  def mask(kind, value)
134
+ return if value.nil?
130
135
  return Constants::REDACTED_VALUE if value == Constants::REDACTED_VALUE
131
136
  return Constants::REDACTED_VALUE unless kind == :secret
132
137
  return Constants::FULL_MASK unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric)
@@ -211,8 +216,7 @@ module Foam
211
216
  mask_kind(name) || (:floor if HTTP_HEADERS.include?(normalize_key(name)))
212
217
  end
213
218
 
214
- # Arrays are masked element by element so their length survives; nil is masked like any value,
215
- # so the export shows the key was set.
219
+ # Arrays are masked element by element so their length survives.
216
220
  def mask_elements(kind, value)
217
221
  value.is_a?(Array) ? value.map { |item| mask(kind, item) } : mask(kind, value)
218
222
  end
@@ -232,20 +236,21 @@ module Foam
232
236
  end
233
237
 
234
238
  # Masks the value after each sensitive key, quoted as JSON writes it or bare. Past 1 MiB the rest
235
- # collapses into one mask, which bounds the work on a hostile line.
239
+ # collapses into one mask, which bounds the work on a hostile line; a value the cut falls
240
+ # inside already ends in that mask.
236
241
  def scan_text(bytes)
237
- return scan_pairs(scan_pairs(bytes, JSON_KEY), BARE_KEY) if bytes.bytesize <= SCAN_BYTE_CAP
242
+ return scan_pairs(bytes) if bytes.bytesize <= SCAN_BYTE_CAP
238
243
 
239
- head = bytes.byteslice(0, SCAN_BYTE_CAP)
240
- scan_pairs(scan_pairs(head, JSON_KEY), BARE_KEY) << Constants::REDACTED_VALUE
244
+ head = scan_pairs(bytes.byteslice(0, SCAN_BYTE_CAP))
245
+ head.end_with?(Constants::REDACTED_VALUE) ? head : head << Constants::REDACTED_VALUE
241
246
  end
242
247
 
243
- # Finds each key with the pattern and masks the value after it when the key is sensitive.
244
- def scan_pairs(bytes, key_pattern)
248
+ # Finds each key, quoted or bare, and masks the value after it when the key is sensitive.
249
+ def scan_pairs(bytes)
245
250
  scanner = StringScanner.new(bytes, fixed_anchor: true)
246
251
  out = nil
247
252
  last = 0
248
- while scanner.scan_until(key_pattern)
253
+ while scanner.scan_until(KEY)
249
254
  next unless masked_key?(scanner, bytes)
250
255
 
251
256
  start = scanner.pos
@@ -260,13 +265,13 @@ module Foam
260
265
  # A key is masked on its name, or on a query-only name when it follows ?, &, or # with a bare =
261
266
  # after it: code, sig and the like are credentials only in a query string.
262
267
  def masked_key?(scanner, bytes)
263
- key = decode_key(scanner[1])
268
+ key = decode_key(scanner[1] || scanner[3])
264
269
  return true if mask_kind(key)
265
270
 
266
271
  start = scanner.pos - scanner.matched_size
267
272
  return false unless start > 0 && QUERY_CONTEXT.include?(bytes.getbyte(start - 1))
268
273
 
269
- scanner[2] == "=" && query_key?(key)
274
+ scanner[4] == "=" && query_key?(key)
270
275
  end
271
276
 
272
277
  # The mask in the quotes the value came with; a quote left open stays open.
@@ -2,27 +2,31 @@
2
2
 
3
3
  require "etc"
4
4
  require "rbconfig"
5
+ require "securerandom"
5
6
  require "socket"
6
7
  require "opentelemetry/sdk"
7
8
  require "opentelemetry-semantic_conventions"
8
9
  require "opentelemetry/semconv/incubating/host"
9
10
  require "opentelemetry/semconv/incubating/os"
11
+ require "opentelemetry/semconv/incubating/process"
10
12
 
11
13
  module Foam
12
14
  module Otel
13
15
  module Internal
14
16
  # A resource is the set of attributes describing the process that produced the telemetry:
15
17
  # which service, environment, host, and OS it came from. Every span, log, and metric
16
- # carries it, and Foam reads the identity from it. This builds Foam's part; the
17
- # configurator merges it over the SDK default, which already holds the process attributes
18
+ # carries it, and Foam reads the identity from it. This builds Foam's part; init
19
+ # merges it over the SDK default, which already holds the process attributes
18
20
  # and OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES.
19
21
  module Resource
20
22
  HOST = OpenTelemetry::SemConv::Incubating::HOST
21
23
  OS = OpenTelemetry::SemConv::Incubating::OS
24
+ PROCESS = OpenTelemetry::SemConv::Incubating::PROCESS
22
25
 
23
26
  IDENTITY_ATTRIBUTES = [
24
27
  OpenTelemetry::SemConv::SERVICE::SERVICE_NAME,
25
28
  OpenTelemetry::SemConv::SERVICE::SERVICE_VERSION,
29
+ OpenTelemetry::SemConv::SERVICE::SERVICE_INSTANCE_ID,
26
30
  OpenTelemetry::SemConv::DEPLOYMENT::DEPLOYMENT_ENVIRONMENT_NAME
27
31
  ].freeze
28
32
 
@@ -49,11 +53,12 @@ module Foam
49
53
 
50
54
  module_function
51
55
 
52
- # Foam's identity merges last so it wins over the caller's attributes and the env.
56
+ # Foam's identity and process merge last, so they win over the caller's attributes and env.
53
57
  def create(name, environment, version, additional_attributes)
54
58
  attributes = host_and_os
55
59
  .merge(without_identity(additional_attributes))
56
60
  .merge(identity(name, environment, version))
61
+ .merge(process)
57
62
  OpenTelemetry::SDK::Resources::Resource.create(attributes)
58
63
  end
59
64
 
@@ -67,11 +72,29 @@ module Foam
67
72
  }.compact
68
73
  end
69
74
 
75
+ # The attributes that name the process rather than the service: its pid and an instance id
76
+ # Foam generates, since nothing in the environment tells two runs of one service apart.
77
+ # A forked child inherits its parent's, so it sets both again.
78
+ def process
79
+ {
80
+ PROCESS::PROCESS_PID => Process.pid,
81
+ OpenTelemetry::SemConv::SERVICE::SERVICE_INSTANCE_ID => SecureRandom.uuid
82
+ }
83
+ end
84
+
85
+ # Sets the process attributes again on the resource of each provider, for the process
86
+ # that holds the providers now. A provider takes its resource once, at construction.
87
+ def refresh(providers)
88
+ update = OpenTelemetry::SDK::Resources::Resource.create(process)
89
+ providers.each do |provider|
90
+ resource = provider.instance_variable_get(:@resource).merge(update)
91
+ provider.instance_variable_set(:@resource, resource)
92
+ end
93
+ end
94
+
70
95
  def without_identity(attributes)
71
96
  (attributes.keys & IDENTITY_ATTRIBUTES).each do |key|
72
- Diagnostics.warn(
73
- "additional_resource_attributes #{key} is set from the init options; ignored"
74
- )
97
+ Diagnostics.warn("additional_resource_attributes #{key} is set by Foam; ignored")
75
98
  end
76
99
  attributes.except(*IDENTITY_ATTRIBUTES)
77
100
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "opentelemetry-logs-sdk"
5
+ require "opentelemetry-metrics-sdk"
6
+
7
+ module Foam
8
+ module Otel
9
+ module Internal
10
+ # The OpenTelemetry globals an SDK registers itself in: the tracer, meter, and logger
11
+ # providers and the propagator. A slot is free while it holds the API's own no-op.
12
+ module Slots
13
+ ACCESSORS = {
14
+ traces: :tracer_provider,
15
+ metrics: :meter_provider,
16
+ logs: :logger_provider,
17
+ baggage: :propagation
18
+ }.freeze
19
+
20
+ SIGNALS = ACCESSORS.keys.freeze
21
+
22
+ DEFAULTS = {
23
+ traces: OpenTelemetry::Internal::ProxyTracerProvider,
24
+ metrics: OpenTelemetry::Internal::ProxyMeterProvider,
25
+ logs: OpenTelemetry::Internal::ProxyLoggerProvider,
26
+ baggage: OpenTelemetry::Context::Propagation::NoopTextMapPropagator
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ def live(signal)
32
+ OpenTelemetry.public_send(ACCESSORS.fetch(signal))
33
+ end
34
+
35
+ def free?(signal)
36
+ live(signal).instance_of?(DEFAULTS.fetch(signal))
37
+ end
38
+
39
+ def claim(signal, object)
40
+ OpenTelemetry.public_send(:"#{ACCESSORS.fetch(signal)}=", object)
41
+ nil
42
+ end
43
+
44
+ # Puts the API's no-op back while the slot still holds object, so the next registration,
45
+ # or a second init, finds the slot free. A slot another SDK claimed since is left in place.
46
+ def release(signal, object)
47
+ claim(signal, DEFAULTS.fetch(signal).new) if live(signal).equal?(object)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -12,7 +12,7 @@ module Foam
12
12
  @initialized = false
13
13
  @signals = NO_SIGNALS
14
14
  @providers = {}
15
- @propagator = nil
15
+ @claimed_slots = {}
16
16
  @taken = []
17
17
  @instrumentations = []
18
18
  @instrumentation_config = {}
@@ -39,19 +39,32 @@ module Foam
39
39
  @signals[signal] != :none
40
40
  end
41
41
 
42
+ # Records the app's name and environment at the start of init, so state names the app
43
+ # even when init stops early.
44
+ def record_params(params)
45
+ @mutex.synchronize { @params = params unless @initialized }
46
+ end
47
+
48
+ # Records a finished init and marks the state initialized. providers holds what init
49
+ # built, keyed :tracer, :meter and :logger, for flush and shutdown to run over.
50
+ # claimed_slots holds the object init placed in each global slot, keyed by signal; a
51
+ # signal reports :global only while that object is still in its slot.
42
52
  def record_init(
43
- params:, tracer_provider:, meter_provider:, logger_provider:, propagator:,
44
- instrumentations:, instrumentation_config:, body_redactor:, network_capture:
53
+ providers:, claimed_slots:, instrumentations:, instrumentation_config:,
54
+ body_redactor:, network_capture:
45
55
  )
46
56
  @mutex.synchronize do
47
- @params = params
48
- @providers = {tracer: tracer_provider, meter: meter_provider, logger: logger_provider}
49
- @propagator = propagator
57
+ @providers = providers
58
+ @claimed_slots = claimed_slots
50
59
  @instrumentations = instrumentations
51
60
  @instrumentation_config = instrumentation_config
52
61
  @body_redactor = body_redactor
53
62
  @network_capture = network_capture
54
- @signals = @signals.merge(traces: :global, metrics: :global, logs: :global, baggage: :global)
63
+ @signals = @signals.merge(
64
+ Slots::SIGNALS.to_h do |signal|
65
+ [signal, claimed_slots.key?(signal) ? :global : without_slot(signal)]
66
+ end
67
+ )
55
68
  @initialized = true
56
69
  end
57
70
  end
@@ -72,13 +85,15 @@ module Foam
72
85
  @mutex.synchronize { @providers.delete(kind) }
73
86
  end
74
87
 
75
- # Shutdown hands the providers back and leaves the state as it was before init, so the
76
- # helpers go quiet again and state stops reporting slots we no longer hold.
88
+ # Shutdown hands the providers back, gives each slot Foam still holds back to the API, and
89
+ # leaves the state as it was before init, so the helpers go quiet again and state stops
90
+ # reporting slots we no longer hold.
77
91
  def take_providers
78
92
  @mutex.synchronize do
93
+ @claimed_slots.each { |signal, object| Slots.release(signal, object) }
79
94
  providers = @providers.values.compact
80
95
  @providers = {}
81
- @propagator = nil
96
+ @claimed_slots = {}
82
97
  @instrumentations = []
83
98
  @instrumentation_config = {}
84
99
  @late = []
@@ -104,10 +119,10 @@ module Foam
104
119
  @mutex.synchronize { @providers[:logger] }
105
120
  end
106
121
 
107
- # The switch for body capture: set only by an init in advanced mode, so the middleware and
108
- # the client patches capture no bodies before init, in basic mode, and after shutdown.
122
+ # The switch for body capture: set only by an init in advanced mode, and off while another
123
+ # SDK holds the logs slot, the rule the Node package follows too.
109
124
  def body_redactor
110
- @mutex.synchronize { @body_redactor }
125
+ @mutex.synchronize { @body_redactor unless %i[local none].include?(source(:logs)) }
111
126
  end
112
127
 
113
128
  # The network capture mode, :off until init runs and again after shutdown, so the Rack
@@ -116,6 +131,22 @@ module Foam
116
131
  @mutex.synchronize { @network_capture }
117
132
  end
118
133
 
134
+ # A forked child inherits the parent's providers. Their resource names the parent process,
135
+ # and the periodic metric reader's export thread did not survive the fork, so the child
136
+ # gets its own process attributes and a running reader. The batch processors reset on
137
+ # their own when the pid changes.
138
+ def after_fork
139
+ Safely.call("after fork") do
140
+ Resource.refresh(providers)
141
+ meter = @mutex.synchronize { @providers[:meter] }
142
+ next unless meter
143
+
144
+ meter.metric_readers.each do |reader|
145
+ reader.after_fork if reader.respond_to?(:after_fork)
146
+ end
147
+ end
148
+ end
149
+
119
150
  private
120
151
 
121
152
  # A library loaded after init is never instrumented, and upstream says nothing about it at the
@@ -129,44 +160,28 @@ module Foam
129
160
  end
130
161
  end
131
162
 
132
- # Anyone can overwrite the OpenTelemetry globals after init, so :global is checked against
133
- # the live slot at call time rather than trusted from init.
134
163
  def live_signals
135
- @signals.to_h do |signal, source|
136
- # We only need to check signals marked as :global
137
- next [signal, source] unless source == :global
138
-
139
- # Make sure that the source is actually in the live slot
140
- live = live_slot(signal)
141
- next [signal, source] if live.equal?(installed(signal))
142
-
143
- # Store overwritten slots in @taken so we only send a warning the first time
144
- unless @taken.include?(signal)
145
- @taken << signal
146
- Diagnostics.warn("#{signal} global slot taken by #{live.class}")
147
- end
148
-
149
- # Foam's logger provider keeps working without the slot, so log still delivers.
150
- [signal, (signal == :logs) ? :local : :none]
151
- end
164
+ @signals.to_h { |signal, _| [signal, source(signal)] }
152
165
  end
153
166
 
154
- def live_slot(signal)
155
- case signal
156
- when :traces then OpenTelemetry.tracer_provider
157
- when :metrics then OpenTelemetry.meter_provider
158
- when :logs then OpenTelemetry.logger_provider
159
- when :baggage then OpenTelemetry.propagation
167
+ # Anyone can overwrite the OpenTelemetry globals after init, so a signal recorded as
168
+ # :global is checked against the live slot at call time, and warned about once when
169
+ # displaced.
170
+ def source(signal)
171
+ return @signals[signal] unless @signals[signal] == :global
172
+ return :global if Slots.live(signal).equal?(@claimed_slots[signal])
173
+
174
+ unless @taken.include?(signal)
175
+ @taken << signal
176
+ Diagnostics.warn("#{signal} global slot taken by #{Slots.live(signal).class}")
160
177
  end
178
+ without_slot(signal)
161
179
  end
162
180
 
163
- def installed(signal)
164
- case signal
165
- when :traces then @providers[:tracer]
166
- when :metrics then @providers[:meter]
167
- when :logs then @providers[:logger]
168
- when :baggage then @propagator
169
- end
181
+ # What a signal reports without its slot. Foam's logger provider keeps working, so log
182
+ # still delivers; the other helpers go quiet.
183
+ def without_slot(signal)
184
+ (signal == :logs) ? :local : :none
170
185
  end
171
186
  end
172
187
  end
@@ -12,8 +12,16 @@ module Foam
12
12
  span = OpenTelemetry::Trace.current_span
13
13
  return unless span.recording?
14
14
 
15
- span.record_exception(exception, attributes: attributes)
16
- span.status = OpenTelemetry::Trace::Status.error(exception.message)
15
+ if exception.is_a?(Exception)
16
+ span.record_exception(exception, attributes: attributes)
17
+ message = exception.message
18
+ else
19
+ # Anything else has no type or backtrace to record, so only its text lands on the event.
20
+ message = exception.to_s
21
+ event_attributes = {"exception.message" => message}.merge(attributes.to_h)
22
+ span.add_event("exception", attributes: event_attributes)
23
+ end
24
+ span.status = OpenTelemetry::Trace::Status.error(message)
17
25
  nil
18
26
  end
19
27
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foam
4
4
  module Otel
5
- VERSION = "3.0.0.alpha.3"
5
+ VERSION = "3.0.0.alpha.4"
6
6
  end
7
7
  end
data/lib/foam/otel.rb CHANGED
@@ -17,13 +17,17 @@ require_relative "otel/internal/body_capture"
17
17
  require_relative "otel/internal/rack/input_tee"
18
18
  require_relative "otel/internal/rack/body_tee"
19
19
  require_relative "rack/middleware"
20
+ require_relative "otel/internal/clients/read_tee"
20
21
  require_relative "otel/internal/clients/net_http"
21
22
  require_relative "otel/internal/clients/excon"
22
23
  require_relative "otel/internal/clients/http_client"
23
24
  require_relative "otel/internal/clients/faraday"
25
+ require_relative "otel/internal/aws_lambda"
24
26
  require_relative "otel/internal/before_send"
25
27
  require_relative "otel/internal/instrumentations"
26
28
  require_relative "otel/internal/ignored_hosts"
29
+ require_relative "otel/internal/slots"
30
+ require_relative "otel/internal/fork_hooks"
27
31
  require_relative "otel/internal/resource"
28
32
  require_relative "otel/internal/init"
29
33
  require_relative "otel/internal/exporters"
@@ -186,13 +190,13 @@ module Foam
186
190
  end
187
191
  end
188
192
 
189
- def flush(timeout: 30)
193
+ def flush(timeout: Internal::Constants::LIFECYCLE_TIMEOUT)
190
194
  Internal::Safely.call("flush") do
191
195
  Internal::Lifecycle.flush(timeout: timeout)
192
196
  end
193
197
  end
194
198
 
195
- def shutdown(timeout: 30)
199
+ def shutdown(timeout: Internal::Constants::LIFECYCLE_TIMEOUT)
196
200
  Internal::Safely.call("shutdown") do
197
201
  Internal::Lifecycle.shutdown(timeout: timeout)
198
202
  end
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.3
4
+ version: 3.0.0.alpha.4
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-14 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-exporter-otlp
@@ -160,6 +160,7 @@ files:
160
160
  - lib/foam-otel.rb
161
161
  - lib/foam/otel.rb
162
162
  - lib/foam/otel/internal.rb
163
+ - lib/foam/otel/internal/aws_lambda.rb
163
164
  - lib/foam/otel/internal/before_send.rb
164
165
  - lib/foam/otel/internal/body_capture.rb
165
166
  - lib/foam/otel/internal/body_decoder.rb
@@ -168,11 +169,13 @@ files:
168
169
  - lib/foam/otel/internal/clients/faraday.rb
169
170
  - lib/foam/otel/internal/clients/http_client.rb
170
171
  - lib/foam/otel/internal/clients/net_http.rb
172
+ - lib/foam/otel/internal/clients/read_tee.rb
171
173
  - lib/foam/otel/internal/constants.rb
172
174
  - lib/foam/otel/internal/content_type.rb
173
175
  - lib/foam/otel/internal/diagnostics.rb
174
176
  - lib/foam/otel/internal/endpoint.rb
175
177
  - lib/foam/otel/internal/exporters.rb
178
+ - lib/foam/otel/internal/fork_hooks.rb
176
179
  - lib/foam/otel/internal/ignored_hosts.rb
177
180
  - lib/foam/otel/internal/ingest.rb
178
181
  - lib/foam/otel/internal/init.rb
@@ -190,6 +193,7 @@ files:
190
193
  - lib/foam/otel/internal/report.rb
191
194
  - lib/foam/otel/internal/resource.rb
192
195
  - lib/foam/otel/internal/safely.rb
196
+ - lib/foam/otel/internal/slots.rb
193
197
  - lib/foam/otel/internal/state.rb
194
198
  - lib/foam/otel/internal/traces.rb
195
199
  - lib/foam/otel/version.rb