foam-otel 3.0.0.alpha.12 → 3.0.0.alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +103 -37
  3. data/lib/foam/otel/internal/before_send.rb +15 -8
  4. data/lib/foam/otel/internal/body_capture.rb +25 -6
  5. data/lib/foam/otel/internal/body_decoder.rb +6 -6
  6. data/lib/foam/otel/internal/body_redactor.rb +3 -3
  7. data/lib/foam/otel/internal/clients/excon.rb +16 -28
  8. data/lib/foam/otel/internal/clients/faraday.rb +15 -21
  9. data/lib/foam/otel/internal/clients/http_client.rb +9 -15
  10. data/lib/foam/otel/internal/clients/net_http.rb +12 -23
  11. data/lib/foam/otel/internal/constants.rb +3 -0
  12. data/lib/foam/otel/internal/exporters.rb +9 -9
  13. data/lib/foam/otel/internal/fork_hooks.rb +4 -5
  14. data/lib/foam/otel/internal/ignored_headers.rb +1 -1
  15. data/lib/foam/otel/internal/ignored_hosts.rb +0 -1
  16. data/lib/foam/otel/internal/ingest.rb +7 -6
  17. data/lib/foam/otel/internal/init.rb +74 -72
  18. data/lib/foam/otel/internal/instrumentations.rb +9 -3
  19. data/lib/foam/otel/internal/lifecycle.rb +14 -4
  20. data/lib/foam/otel/internal/log_record_processor.rb +5 -4
  21. data/lib/foam/otel/internal/otlp.rb +1 -0
  22. data/lib/foam/otel/internal/patch_resolver.rb +6 -7
  23. data/lib/foam/otel/internal/propagation.rb +15 -25
  24. data/lib/foam/otel/internal/que.rb +6 -6
  25. data/lib/foam/otel/internal/rails_middleware.rb +12 -8
  26. data/lib/foam/otel/internal/redactor.rb +75 -31
  27. data/lib/foam/otel/internal/resource.rb +3 -5
  28. data/lib/foam/otel/internal/safely.rb +7 -4
  29. data/lib/foam/otel/internal/state.rb +26 -35
  30. data/lib/foam/otel/version.rb +1 -1
  31. data/lib/foam/rack/middleware.rb +31 -18
  32. metadata +4 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b67fa02fd12d317a0bceff3c596f63d928b0187995d02e95191f9ecb05b7383e
4
- data.tar.gz: 6933cac173a8df6fa83fe474f06fd59569d0da61eb4a78f16919c695561b63af
3
+ metadata.gz: '026729cab71cf3ea55acaf802d5efc5f6e2d71e6de6ae72f5ed57c1181f11dd2'
4
+ data.tar.gz: 1dc901d627cd9e326cfc5fa5f9339d53e2d9af3c9de3b2e14f64a9d4784dcd77
5
5
  SHA512:
6
- metadata.gz: a7080090c2024fda65b0bf8d76edbe311961ae7c26814cd3154c11e9c7735e9730c10e100468d1046acc94e1cb2b2d1518b50cdc72b631201187b271b167c722
7
- data.tar.gz: 184764049f1395f94f270cec7054138cfd78c8f6b40c93c3cafafdcb4cd7a3754bfa1eed0851e47d1f96258f10e1dea51889624862e07f86579e42d73a26353a
6
+ metadata.gz: e037404d73d8dcc83dbadb700b5395c41097706e2b2772597c33a577fb5b5a806df0eb0546d09dc05202ab56e49e3df0f7b2ea1f55025156691d0d014feecbf6
7
+ data.tar.gz: 64bcbecd4e4c539bc4190ba35c9a7845778775857180190408371c75321fdd17434e5e6698d97ab9a69dc2f1c5859d569ba69d4b027ae9f3ffb0c7598916c32e
data/README.md CHANGED
@@ -23,54 +23,62 @@ bundle install
23
23
 
24
24
  ## Initialize
25
25
 
26
- Call `init` once, after the app's gems are loaded and before the app runs. Set `enabled: false` to turn Foam off.
26
+ Call `init` once, after the app's gems are loaded and before the app runs. Set `enabled` to `true` only in the deployed environments that send telemetry to Foam, such as production.
27
27
 
28
28
  Store the ingest token as `FOAM_OTEL_TOKEN` and pass `ENV["FOAM_OTEL_TOKEN"]` to `init`.
29
29
 
30
30
  ### Rails
31
31
 
32
- Create `config/initializers/foam.rb`:
32
+ Call `init` after every file in `config/initializers` and before Rails builds the middleware stack. Add an initializer to the application class in `config/application.rb`:
33
33
 
34
34
  ```ruby
35
- Foam.init(
36
- name: "checkout-api",
37
- environment: Rails.env,
38
- enabled: true,
39
- token: ENV["FOAM_OTEL_TOKEN"]
40
- )
35
+ module CheckoutApi
36
+ class Application < Rails::Application
37
+ initializer "foam.init", after: :load_config_initializers do
38
+ Foam.init(
39
+ name: "checkout-api",
40
+ environment: Rails.env,
41
+ enabled: Rails.env.production?,
42
+ token: ENV["FOAM_OTEL_TOKEN"]
43
+ )
44
+ end
45
+ end
46
+ end
41
47
  ```
42
48
 
43
- `init` must run before the app initializes, either with an initializer file or in `config/application.rb`. An `init` from `after_initialize` or later warns on stderr and adds no middleware. During boot it inserts `Foam::Rack::Middleware` directly after the OpenTelemetry Rack middleware. `config.ru` stays as generated.
49
+ `init` inserts `Foam::Rack::Middleware` directly after the OpenTelemetry Rack middleware. `config.ru` stays as generated. An `init` from `after_initialize` or later warns on stderr and adds no middleware.
44
50
 
45
51
  Do not load Foam with a `RUBYOPT` preload. It runs before Rails loads, so it installs no Rails instrumentation and adds no middleware.
46
52
 
47
53
  ### Plain Ruby and Rack
48
54
 
49
- Call `init` at the top of `config.ru`, then add the OpenTelemetry Rack middleware and Foam's:
55
+ Call `init` in `config.ru` after the app's requires, then add Foam's middleware:
50
56
 
51
57
  ```ruby
52
58
  require "foam/otel"
59
+ require_relative "app"
53
60
 
54
61
  Foam.init(
55
62
  name: "checkout-api",
56
63
  environment: ENV["RACK_ENV"],
57
- enabled: true,
64
+ enabled: ENV["RACK_ENV"] == "production",
58
65
  token: ENV["FOAM_OTEL_TOKEN"]
59
66
  )
60
67
 
61
- use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
62
68
  use Foam::Rack::Middleware
63
69
 
64
70
  run App
65
71
  ```
66
72
 
73
+ `Foam::Rack::Middleware` runs the OpenTelemetry Rack middleware, which opens the server span. When `init` is disabled or stops early, the middleware passes every request through.
74
+
67
75
  Foam's middleware records the `content-type`, `content-length`, and `content-encoding` headers as server span attributes. In `:advanced` mode it also sends the bodies as log records correlated to the span. See Network capture.
68
76
 
69
77
  ## Instrumentation
70
78
 
71
79
  `init` installs OpenTelemetry's Ruby instrumentation bundle, `opentelemetry-instrumentation-all`, and the standard library logger bridge, `opentelemetry-instrumentation-logger`. An instrumentation installs only when its library is loaded before `init`, so call `init` after `Bundler.require` or after the app's own requires. A library loaded after `init` is not instrumented, and a warning is written to stderr once when `state` finds one. `state[:instrumentations]` lists what installed.
72
80
 
73
- The bundle covers: action_mailer, action_pack, action_view, active_job, active_model_serializers, active_record, active_storage, active_support, anthropic, aws_lambda, aws_sdk, bunny, concurrent_ruby, dalli, delayed_job, ethon, excon, faraday, grape, graphql, grpc, gruf, http, http_client, httpx, koala, lmdb, mongo, mysql2, net_http, pg, que, racecar, rack, rails, rake, rdkafka, redis, resque, restclient, ruby_kafka, sidekiq, sinatra, trilogy.
81
+ The bundle covers: action_mailer, action_pack, action_view, active_job, active_model_serializers, active_record, active_storage, active_support, anthropic, aws_lambda, aws_sdk, bunny, concurrent_ruby, dalli, delayed_job, ethon, excon, faraday, grape, graphql, grpc, gruf, http, http_client, httpx, koala, lmdb, mongo, mysql2, net_http, openai, pg, que, racecar, rack, rails, rake, rdkafka, redis, resque, restclient, ruby_kafka, sidekiq, sinatra, trilogy.
74
82
 
75
83
  To install an instrumentation outside the bundle, list it in `additional_instrumentations`.
76
84
 
@@ -88,11 +96,19 @@ To disable one instrumentation or change its options, pass it in `additional_ins
88
96
 
89
97
  TODO(jhartquist): `db_capture` init option. The `pg`, `mysql2`, and `trilogy` instrumentations can include the statement (`db_statement: :include`) and `pg` can report row counts (`db_response_returned_rows`, `db_response_affected_rows`), but none hands over results. Result capture needs Foam patches on the drivers, opt-in, through the same redaction and size caps as bodies. See Expanded data capture at the end of this document.
90
98
 
91
- TODO(jhartquist): Gen AI providers beyond `anthropic`. There is no OpenTelemetry Ruby instrumentation for the `openai` or `ruby-openai` gems.
99
+ TODO(jhartquist): Gen AI providers beyond `anthropic` and `openai`. There is no OpenTelemetry Ruby instrumentation for the `ruby-openai` gem.
92
100
 
93
101
  ## Public API
94
102
 
95
- Every method lives on the `Foam` module. A missing or invalid value is reported in Foam's log output instead of raising; only an unknown keyword raises `ArgumentError`. `set_baggage`, the metric helpers, and `log` need a Foam export path to function properly, so they are no-ops before `init`; the trace context helpers and `record_exception` work on the current OpenTelemetry context whether or not `init` ran.
103
+ Every method lives on the `Foam` module. A call with wrong arguments raises `ArgumentError`: an unknown keyword, a missing required keyword, or a wrong argument count. Any other problem, such as a blank or invalid value, is written to stderr with a `[foam-otel]` prefix, and the method returns without raising.
104
+
105
+ These helpers do nothing until Foam has a way to export:
106
+
107
+ - `set_baggage` works after `init`.
108
+ - The metric helpers work after `init`, or with `create_foam_ingest_metric_reader` attached.
109
+ - `log` works after `init`, or once `create_foam_ingest_log_record_processor` is built.
110
+
111
+ The trace context helpers and `record_exception` work on the current OpenTelemetry context, with or without `init`.
96
112
 
97
113
  ### `init`
98
114
 
@@ -140,7 +156,7 @@ Foam.init(
140
156
 
141
157
  - `name` - the service name, sent as `service.name`.
142
158
  - `environment` - the deployment environment, sent as `deployment.environment.name`.
143
- - `enabled` - `false` turns Foam off; `init` returns without configuring OpenTelemetry. With a token, it still reports the outcome (see below).
159
+ - `enabled` - `false` turns Foam off; `init` returns without configuring OpenTelemetry. With a token, it still reports the outcome (see below). `OTEL_SDK_DISABLED=true` also turns Foam off.
144
160
  - `token` - the Foam ingest token, sent as a bearer token on every export. Required when Foam is enabled; a missing token is reported on stderr and leaves Foam uninitialized.
145
161
  - `version` - the service version, sent as `service.version` when given.
146
162
  - `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.
@@ -164,11 +180,11 @@ Every signal carries a resource with these attributes:
164
180
  - `host.name`, `host.arch`, `host.id` when it can be read, `os.type`, and `os.version`.
165
181
  - The process and SDK attributes the OpenTelemetry SDK adds.
166
182
 
167
- A process forked after `init` carries its own `service.instance.id` and `process.pid`. `OTEL_RESOURCE_ATTRIBUTES` can add further attributes. It cannot override any of the attributes above, and `OTEL_SERVICE_NAME` cannot override `name`.
183
+ A process forked after `init` carries its own `service.instance.id` and `process.pid`. `OTEL_RESOURCE_ATTRIBUTES` can add further attributes and override the process and SDK attributes the OpenTelemetry SDK adds, except `process.pid`. It cannot override the other attributes above, and `OTEL_SERVICE_NAME` cannot override `name`.
168
184
 
169
- `init` raises only `ArgumentError`, for a missing `name` or `environment` or an unknown keyword. Any other failure, or an option holding an invalid value, is reported on stderr with a `[foam-otel]` prefix and leaves `state` uninitialized.
185
+ `init` raises `ArgumentError` only when the `name` or `environment` keyword is left out or a keyword is unknown. A blank value, an invalid option value, or any other failure is written to stderr with a `[foam-otel]` prefix and leaves `state` uninitialized.
170
186
 
171
- `init` also reports its outcome to Foam: one log record with the message and the `state`, sent straight to the endpoint with the token. The report is sent in the background and never blocks the app. When the process exits before the report has been delivered, exit waits up to two seconds for the send to finish; a report that cannot be delivered is dropped. Nothing is sent without a token.
187
+ `init` also reports its outcome to Foam: one log record with the message and the `state`, sent straight to the endpoint with the token. `init` resolves the endpoint's host before it returns, which takes up to 1 second when DNS is slow. The report itself is sent in the background. 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.
172
188
 
173
189
  ### `set_endpoint(url)`
174
190
 
@@ -219,15 +235,20 @@ meter_provider.add_metric_reader(reader)
219
235
 
220
236
  All three export to the endpoint set by `set_endpoint` and take the same `redact` option as `init`. The span processor and the log record processor also take the same `before_send` option; the metric reader does not. Each one records its signal as `ingest` in `state` and reports the attachment to Foam.
221
237
 
222
- Foam identifies the service by the `name` and `environment` passed in: on the copy sent to Foam they overwrite the provider's `service.name` and `deployment.environment.name`, and `foam.ingest.host` names the host of the endpoint the copy goes to. The provider's own telemetry is left unchanged. Pass the same values to all three.
238
+ Foam identifies the service by the `name` and `environment` passed in. On the copy sent to Foam, they overwrite the provider's `service.name` and `deployment.environment.name`. The copy also gets `foam.ingest.host`, the host of the endpoint it goes to. The provider's own telemetry stays unchanged. Pass the same values to all three.
223
239
 
224
- A missing or blank `name`, `environment`, or `token`, or an invalid `redact` or `before_send` value, is reported in Foam's log output and turns off just that helper: it returns a component that exports nothing, which the app adds to its provider as usual, and the signal stays `none` in `state`, so an unset environment variable never stops the app from booting.
240
+ Only that helper turns off when it gets one of these values:
241
+
242
+ - a missing or blank `name`, `environment`, or `token`
243
+ - an invalid `redact` or `before_send` value
244
+
245
+ It writes the problem to stderr and returns a component that exports nothing. The app adds that component to its provider as usual, and the signal stays `none` in `state`. An unset environment variable never stops the app from booting.
225
246
 
226
247
  Do not combine `init` and an ingest component for the same signal. The ingest components do not install instrumentations; register the ones the app needs with the OpenTelemetry SDK directly.
227
248
 
228
249
  ### `inject_trace_context(carrier)`
229
250
 
230
- Writes the W3C `traceparent`, `tracestate`, and `baggage` headers for the current span into `carrier`, a hash of string keys. A `tracestate` the carrier already holds is kept, since Foam adds no entry of its own and another SDK may have added one.
251
+ Writes the W3C `traceparent`, `tracestate`, and `baggage` headers for the current span into `carrier`, a hash of string keys. A `tracestate` the carrier already holds is kept, since Foam adds no entry of its own and another SDK may have added one. A `traceparent` the carrier already holds is overwritten.
231
252
 
232
253
  Use it when the app sends a message itself (a Kafka record, a queue payload, a custom socket). The HTTP instrumentations already inject these headers; do not call it on ordinary HTTP requests. Without an active span it writes only `baggage`, and only when baggage is set.
233
254
 
@@ -237,8 +258,6 @@ Foam.inject_trace_context(headers)
237
258
  producer.publish(topic, payload, headers: headers)
238
259
  ```
239
260
 
240
- TODO(jhartquist): a carrier that already holds a `traceparent` has it overwritten. Decide whether to keep the existing one, replace it, or link the two.
241
-
242
261
  ### `extract_trace_context(carrier)`
243
262
 
244
263
  Reads those headers from `carrier` and returns an OpenTelemetry context. Run the receiving work inside it so its spans continue the sender's trace. Returns the current context when the carrier has no trace headers.
@@ -335,11 +354,17 @@ Foam.flush(timeout: 5)
335
354
 
336
355
  ### `shutdown(timeout: 10)`
337
356
 
338
- Flushes and shuts down Foam's providers, including the one behind `log` when an ingest component created it, and the ingest components, and releases the global slots Foam claimed. `init` and each ingest factory register it with `at_exit`, so most apps never call it, and an app that never shuts its own providers down still delivers the ingest components' last batch. An app calls it before a process is killed without a normal exit.
357
+ Flushes and shuts down Foam's providers and the ingest components, and releases the global slots Foam claimed. The providers include the one behind `log` when an ingest component created it. The first `init` or ingest factory call registers it with `at_exit`, once per process. Most apps never call it. An app that never shuts its own providers down still delivers the ingest components' last batch. An app calls it before a process is killed without a normal exit.
339
358
 
340
359
  `timeout` is in seconds and bounds the whole call. A provider that runs out of time is reported on stderr.
341
360
 
342
- `at_exit` covers a normal exit, an unhandled exception, and a signal the process handles by exiting. The last batch is lost when the process ends without running `at_exit`, as with `exit!`, a `KILL` signal, or a crash of the Ruby process itself, and when a batch outlasts the timeout. An exception outside any span is invisible unless the app logs it.
361
+ `at_exit` covers a normal exit, an unhandled exception, and a signal the process handles by exiting. The last batch is lost only in these cases:
362
+
363
+ - The process ends without running `at_exit`: `exit!`, a `KILL` signal, or a crash of the Ruby process itself.
364
+ - A batch outlasts the timeout.
365
+ - The export fails, for example when the endpoint is unreachable.
366
+
367
+ An exception outside any span is invisible unless the app logs it.
343
368
 
344
369
  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.
345
370
 
@@ -365,7 +390,7 @@ Foam.state
365
390
  # }
366
391
  ```
367
392
 
368
- `instrumentations` lists the instrumentations that installed, by gem name without the `opentelemetry-instrumentation-` prefix, with the version of the library each one instruments (`net_http` reports the Ruby version; `aws_lambda` has no version). An instrumentation whose library is absent or unsupported is left out.
393
+ `instrumentations` lists each instrumentation that installed, by gem name without the `opentelemetry-instrumentation-` prefix. Each entry carries the version of the library it instruments, as `name@version`. `net_http` reports the Ruby version, and `aws_lambda` has no version. An instrumentation whose library is absent or unsupported is left out.
369
394
 
370
395
  A library loaded after `init` is not instrumented and stays out of the list; a warning is written to stderr the first time a `state` call finds one. See Instrumentation for the bundle.
371
396
 
@@ -390,15 +415,28 @@ TODO(jhartquist): custom spans and the OpenTelemetry API: getting a tracer, `in_
390
415
 
391
416
  - `:off` keeps the standard OpenTelemetry HTTP telemetry, with no extra capture.
392
417
  - `:basic` adds the `content-type`, `content-length`, and `content-encoding` headers of the request and the response as `http.request.header.<name>` and `http.response.header.<name>` span attributes. No other header attribute is added.
393
- - `:advanced` adds the request and response bodies as well.
418
+ - `:advanced` adds the request and response bodies as well, and turns on the `openai` instrumentation's `capture_content`: prompts and completions arrive as `gen_ai` log records, redacted like bodies. `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` takes precedence. The `anthropic` instrumentation has no content capture; its prompts arrive only as captured `Net::HTTP` request bodies.
394
419
 
395
420
  There is no option to record other headers.
396
421
 
397
- Server spans get the capture from `Foam::Rack::Middleware`, placed after the OpenTelemetry Rack middleware (added by `init` in Rails apps). Client spans get it from `Net::HTTP`, Faraday with any adapter, Excon, and HTTPClient when their gem is loaded before `init`. RestClient and HTTParty are captured on the `Net::HTTP` span beneath them. `http`, `httpx`, and `ethon` get no capture, and neither does a Faraday connection built before `init`.
422
+ Server spans get the capture from `Foam::Rack::Middleware`, which runs inside the OpenTelemetry Rack middleware (added by `init` in Rails apps). Client spans get it from `Net::HTTP`, Faraday with any adapter, Excon, and HTTPClient when their gem is loaded before `init`. RestClient and HTTParty are captured on the `Net::HTTP` span beneath them. `http`, `httpx`, and `ethon` get no capture, and neither does a Faraday connection built before `init`.
423
+
424
+ Foam captures these streamed bodies:
425
+
426
+ - a Rack response body the server sends through `each`
427
+ - a Rack request body, including the part the app leaves unread when `rack.input` can seek
428
+ - a `Net::HTTP` request body sent through `body_stream`
429
+ - an HTTPClient response read in a block
398
430
 
399
- Streamed bodies are captured for a Rack response the server iterates, a Rack request body as the app reads it, a `Net::HTTP` request sent through `body_stream`, and an HTTPClient response read in a block. Other streamed bodies are not, such as a Rack `call` body, a file sent by path, a hijacked connection, a client request body sent from an IO, or a client response read in a block or through a streaming callback.
431
+ Foam captures no other streamed body. For example:
400
432
 
401
- TODO(jhartquist): cover `http`, `httpx`, `ethon`, and `restclient`.
433
+ - a Rack response body that responds to `call`
434
+ - a file the server sends by path (`to_path`)
435
+ - a hijacked connection (`rack.hijack`)
436
+ - an Excon, Faraday, or HTTPClient request body sent from an IO
437
+ - an Excon `request_block` or an HTTPClient multipart body
438
+ - a `Net::HTTP` response read in a block or into an IO
439
+ - an Excon response read through `response_block` or a Faraday response read through `on_data`
402
440
 
403
441
  ### Bodies
404
442
 
@@ -407,7 +445,7 @@ TODO(jhartquist): cover `http`, `httpx`, `ethon`, and `restclient`.
407
445
  - At most 1 MiB of a body is kept, sent as records of at most 64 KiB each. The rest counts toward the size.
408
446
  - Bodies are parsed by content type and redacted with the same keys as span attributes. See Redaction.
409
447
  - A server request body is captured whether or not the app reads it when the input is rewindable, as it is under Puma. Otherwise only what the app reads is captured.
410
- - A streamed server response is observed as it is sent, without buffering or delaying it. Its records are emitted when the body closes; a stream idle for over 60 seconds is closed as incomplete and the rest is dropped.
448
+ - A streamed server response is observed as it is sent, without buffering or delaying it. Its records are emitted when the body closes. A stream that stalls for over 60 seconds can be recorded as incomplete.
411
449
 
412
450
  ### Known issue
413
451
 
@@ -437,13 +475,39 @@ Secrets in a log line are masked before export. See Redaction.
437
475
 
438
476
  ## AWS Lambda
439
477
 
478
+ Wrap the function's handler with the `aws_lambda` instrumentation's handler, and call `init` in the wrapper file after the handler file loads.
479
+
480
+ 1. Add `foam_lambda.rb` next to the handler file:
481
+
482
+ ```ruby
483
+ require "foam/otel"
484
+ require_relative "function"
485
+
486
+ Foam.init(name: "checkout-fn", environment: "production", token: ENV["FOAM_OTEL_TOKEN"])
487
+
488
+ FOAM_HANDLER = OpenTelemetry::Instrumentation::AwsLambda::Handler.new
489
+
490
+ def foam_handler(event:, context:)
491
+ FOAM_HANDLER.call_wrapped(event: event, context: context)
492
+ end
493
+ ```
494
+
495
+ 2. Set the function's handler to `foam_lambda.foam_handler`.
496
+ 3. Set the `ORIG_HANDLER` environment variable to the original handler, such as `function.handler`.
497
+
440
498
  The invocation span carries `faas.trigger` for API Gateway, ALB, SNS, SQS, S3, DynamoDB streams, and EventBridge schedule events.
441
499
 
442
- Traces, metrics, and logs are flushed before the handler returns (including failing invocations).
500
+ Traces, metrics, and logs are flushed before the handler returns, including failing invocations.
443
501
 
444
502
  ## Pre-forking servers and threads
445
503
 
446
- Call `init` once, in the process that forks: the master of Puma in cluster mode with `preload_app!` (the default since Puma 7), the master of Unicorn with `preload_app true`, or a worker that forks a child per job. Each child exports its own traces, logs, and metrics, carrying its own `process.pid`.
504
+ Call `init` once, in the process that forks:
505
+
506
+ - the master of Puma in cluster mode with `preload_app!` (the default since Puma 7)
507
+ - the master of Unicorn with `preload_app true`
508
+ - a worker that forks a child per job
509
+
510
+ Each child exports its own traces, logs, and metrics, with its own `process.pid`.
447
511
 
448
512
  The current span and baggage are stored per fiber. A `Thread.new` or `Fiber.new` starts with none, so spans started inside it begin a new trace. To continue the trace, capture the context first and make it current inside the thread:
449
513
 
@@ -501,11 +565,11 @@ Foam.init(
501
565
  Sentry sends its events to the DSN's host over HTTP, which Foam would otherwise record as spans. Pass that address and Sentry's request header to `init`. This works whether Sentry sends inline or from its background worker:
502
566
 
503
567
  ```ruby
504
- dsn = Sentry.configuration.dsn
568
+ dsn = Sentry.configuration&.dsn
505
569
 
506
570
  Foam.init(
507
571
  name: "checkout-api", environment: "production", token: ENV["FOAM_OTEL_TOKEN"],
508
- ignored_outbound_hosts: ["#{dsn.host}:#{dsn.port}"],
572
+ ignored_outbound_hosts: dsn ? ["#{dsn.host}:#{dsn.port}"] : [],
509
573
  ignored_outbound_headers: ["X-Sentry-Auth"]
510
574
  )
511
575
  ```
@@ -521,7 +585,7 @@ OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMa
521
585
 
522
586
  ### New Relic
523
587
 
524
- New Relic's agent sends its data to its collector over HTTP, and probes the AWS, Azure and GCP metadata endpoints when it connects, which Foam would otherwise record as spans. Pass the collector's address and the metadata hosts to `init`. The agent's requests carry no header of its own, so there is none to pass:
588
+ New Relic's agent sends its data to its collector over HTTP. When it connects, it also probes the AWS, Azure, and GCP metadata endpoints. Foam records each of these requests as a span unless its host is ignored. The agent's requests carry no header of their own, so there is no header to ignore. Pass the collector's address and the metadata hosts to `init`:
525
589
 
526
590
  ```ruby
527
591
  collector = "#{NewRelic::Agent.config[:host]}:#{NewRelic::Agent.config[:port]}"
@@ -536,6 +600,8 @@ Foam.init(
536
600
  )
537
601
  ```
538
602
 
603
+ TODO(jhartquist): confirm the host New Relic's agent sends its data to. The agent sends `preconnect` to `Agent.config[:host]` and every later request to the `redirect_host` in the reply, while the list above names the configured host only. New Relic's network docs list exact collector hosts per region, so the redirect likely names the same host, but mosh's receiver never redirects. Check with a real license key and a debug agent log, in the US and EU regions.
604
+
539
605
  Start New Relic before `init`, as Rails does through the agent's Railtie. If the agent instruments Net::HTTP in chain mode and starts after `init`, every Net::HTTP request raises `SystemStackError`. The agent uses chain mode when `instrumentation.net_http` is `chain`, or on its own next to ScoutApm, Rack::MiniProfiler, or Airbrake before 10.0.2. Where the order cannot change, set `instrumentation.net_http` to `prepend`.
540
606
 
541
607
  If the app turns on New Relic's OpenTelemetry bridge (`opentelemetry.enabled`), New Relic takes the tracer provider and propagator, and Foam records no traces. Logs and metrics still reach Foam.
@@ -19,10 +19,10 @@ module Foam
19
19
  freeze
20
20
  end
21
21
 
22
- # Called by the exporters with each hook event they built; returns what to send, or nil to drop it.
23
- # A hook that raises, returns something else, or gets an event that cannot be copied changes
24
- # nothing, so a broken hook never loses telemetry. The block receives what the hook raised, so
25
- # the exporter can say so once for its batch.
22
+ # Returns what to send for a hook event the exporters built, or nil to drop it. The event goes
23
+ # out unchanged when the hook raises, returns something other than an event of the same type,
24
+ # or gets an event that cannot be copied. A broken hook never loses telemetry. The block
25
+ # receives what the hook raised, and the exporter reports it once for its batch.
26
26
  def event_to_export(hook_event)
27
27
  return hook_event unless @hook
28
28
 
@@ -59,17 +59,24 @@ module Foam
59
59
  result[:links].is_a?(Array) && result[:links].all? { |entry| span_link_entry?(entry) }
60
60
  end
61
61
 
62
+ # Attributes that are not a Hash, or a timestamp that is not an Integer, would fail the export
63
+ # of the whole batch.
62
64
  def span_event_entry?(entry)
63
- entry.is_a?(Hash) && entry[:name].is_a?(String)
65
+ entry.is_a?(Hash) && entry[:name].is_a?(String) &&
66
+ optional?(entry[:attributes], Hash) && optional?(entry[:timestamp], Integer)
64
67
  end
65
68
 
66
69
  def span_link_entry?(entry)
67
- entry.is_a?(Hash) && entry[:span_context].is_a?(OpenTelemetry::Trace::SpanContext)
70
+ entry.is_a?(Hash) && entry[:span_context].is_a?(OpenTelemetry::Trace::SpanContext) &&
71
+ optional?(entry[:attributes], Hash)
68
72
  end
69
73
 
70
74
  def log_fields?(result)
71
- (result[:severity_text].nil? || result[:severity_text].is_a?(String)) &&
72
- (result[:severity_number].nil? || result[:severity_number].is_a?(Integer))
75
+ optional?(result[:severity_text], String) && optional?(result[:severity_number], Integer)
76
+ end
77
+
78
+ def optional?(value, type)
79
+ value.nil? || value.is_a?(type)
73
80
  end
74
81
 
75
82
  NONE = new(nil)
@@ -78,8 +78,9 @@ module Foam
78
78
  # - Foam has no logger provider
79
79
  # - the limit is reached
80
80
  # The block returns the headers as lowercase names to values; it runs at finalize, so
81
- # headers set after the body started still count.
82
- def start(span, body_redactor, side:, direction:, &headers)
81
+ # headers set after the body started still count. body_redactor defaults to the one init
82
+ # built.
83
+ def start(span, side:, direction:, body_redactor: State.body_redactor, &headers)
83
84
  Safely.call("#{direction} body capture") do
84
85
  return unless body_redactor
85
86
  return unless span.recording? && span.context.trace_flags.sampled?
@@ -97,6 +98,14 @@ module Foam
97
98
  end
98
99
  end
99
100
 
101
+ # Starts the request capture of a client span and gives it the whole body. Returns the
102
+ # capture, or nil when start captures nothing.
103
+ def client_request(span, body, &headers)
104
+ capture = start(span, side: :client, direction: :request, &headers)
105
+ capture&.observe(body)
106
+ capture
107
+ end
108
+
100
109
  # The clock the deadline is measured on, in monotonic seconds.
101
110
  def now
102
111
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -123,13 +132,23 @@ module Foam
123
132
  # - the current span is not a recording client span, as for a sampled-out request or a
124
133
  # host the instrumentation leaves untraced
125
134
  # - a middleware above the transport owns the span and captures on it itself
135
+ # - the lookup failed
126
136
  def client_span
127
- return if OwnRequest.current?
137
+ Safely.call("body capture") do
138
+ return if OwnRequest.current?
139
+
140
+ span = OpenTelemetry::Trace.current_span
141
+ return unless span.recording? && span.kind == :client
128
142
 
129
- span = OpenTelemetry::Trace.current_span
130
- return unless span.recording? && span.kind == :client
143
+ span unless OpenTelemetry::Context.current.value(OWNER)&.span.equal?(span)
144
+ end
145
+ end
131
146
 
132
- span unless OpenTelemetry::Context.current.value(OWNER)&.span.equal?(span)
147
+ # Returns the safe headers that are present, as lowercase names to values. The block finds
148
+ # the value of each name. Without a block, the lookup in hash ignores the case of names.
149
+ def safe_headers(hash = nil, &lookup)
150
+ lookup ||= ->(name) { hash[name] || hash.find { |key, _value| key.casecmp?(name) }&.last }
151
+ Constants::SAFE_NETWORK_HEADERS.to_h { |name| [name, lookup.call(name)] }.compact
133
152
  end
134
153
 
135
154
  # Sets the safe headers of one direction on the span, in the semantic conventions' dash
@@ -9,8 +9,6 @@ module Foam
9
9
  # encoding within a bound, reads the charset, cuts the text at the capture limit, and splits
10
10
  # it into chunks. A body that is not text yields no text, and only its metadata is recorded.
11
11
  module BodyDecoder
12
- INFLATED_ENCODINGS = %w[gzip x-gzip deflate].freeze
13
-
14
12
  # How much of a body of unknown type is looked at to tell whether it is text.
15
13
  SNIFF_BYTES = 1024
16
14
  # Control characters other than tab, line feed, form feed, and carriage return.
@@ -33,9 +31,11 @@ module Foam
33
31
  end
34
32
 
35
33
  # Returns the body as UTF-8 text and whether it was cut at the capture limit. Invalid
36
- # bytes are replaced and the cut lands on a character boundary. Returns nil when the
37
- # content type is binary, or unknown or missing and the bytes do not read as text, or the
38
- # bytes are a file whatever the type says. A multipart body's parts are judged on their own.
34
+ # bytes are replaced, and the cut lands on a character boundary. Returns nil when:
35
+ # - the content type is binary
36
+ # - the content type is unknown or missing, and the bytes do not read as text
37
+ # - the bytes are a file, whatever the type says
38
+ # A multipart body is never judged a file as a whole; its parts are judged on their own.
39
39
  def text(bytes, content_type)
40
40
  readable = content_type.text? || (!content_type.binary? && textual?(bytes))
41
41
  readable &&= content_type.multipart? || !file?(bytes)
@@ -84,7 +84,7 @@ module Foam
84
84
  # framing alike, and its block form hands over the output in pieces, so inflating stops
85
85
  # as soon as the bound is passed.
86
86
  def inflate(data, encoding)
87
- return unless INFLATED_ENCODINGS.include?(encoding)
87
+ return unless Constants::INFLATED_ENCODINGS.include?(encoding)
88
88
 
89
89
  inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
90
90
  output = +""
@@ -48,9 +48,9 @@ module Foam
48
48
  freeze
49
49
  end
50
50
 
51
- # Masks the values under sensitive keys as the content type is read; a body of unknown or
52
- # missing type is read as plain text, a binary type is left alone, and a failure masks the
53
- # whole body rather than leak it.
51
+ # Masks the values under sensitive keys, reading the body by its content type. A body of
52
+ # unknown or missing type is read as plain text, and a binary type is left alone. A failure
53
+ # masks the whole body, so nothing leaks.
54
54
  def redact(text, content_type)
55
55
  type = ContentType.parse(content_type)
56
56
  redacted =
@@ -13,9 +13,6 @@ module Foam
13
13
  # response or the error.
14
14
  REQUEST_CAPTURE = :foam_request_capture
15
15
 
16
- # The encodings Excon's Decompress middleware inflates.
17
- DECOMPRESSED = %w[gzip x-gzip deflate].freeze
18
-
19
16
  def self.install
20
17
  return unless Instrumentations.installed?(INSTRUMENTATION)
21
18
 
@@ -33,7 +30,7 @@ module Foam
33
30
  # Captures a String request body before it is written and keeps the capture on the
34
31
  # datum until the response or the error arrives.
35
32
  def request_call(datum)
36
- span = Safely.call("body capture") { BodyCapture.client_span }
33
+ span = BodyCapture.client_span
37
34
  if span
38
35
  datum[REQUEST_CAPTURE] =
39
36
  Safely.call("request body capture") { capture_request(datum, span) }
@@ -46,15 +43,18 @@ module Foam
46
43
  # headers of both directions, now that Excon has written the request's.
47
44
  def response_call(datum)
48
45
  datum.delete(REQUEST_CAPTURE)&.finalize(complete: true)
49
- if (span = Safely.call("body capture") { BodyCapture.client_span })
46
+ span = BodyCapture.client_span
47
+ if span
50
48
  Safely.call("request header capture") do
51
- BodyCapture.set_header_attributes(span, :request, headers(datum[:headers]))
49
+ headers = BodyCapture.safe_headers(datum[:headers])
50
+ BodyCapture.set_header_attributes(span, :request, headers)
52
51
  end
53
52
  Safely.call("response header capture") do
54
- BodyCapture.set_header_attributes(span, :response, headers(datum[:response][:headers]))
53
+ headers = BodyCapture.safe_headers(datum[:response][:headers])
54
+ BodyCapture.set_header_attributes(span, :response, headers)
55
55
  end
56
56
  end
57
- Safely.call("response body capture") { capture_response(datum) }
57
+ Safely.call("response body capture") { capture_response(datum, span) }
58
58
  @stack.response_call(datum)
59
59
  end
60
60
 
@@ -70,25 +70,20 @@ module Foam
70
70
  body = datum[:body]
71
71
  return unless body.is_a?(String) && !datum.key?(:request_block)
72
72
 
73
- capture = BodyCapture.start(
74
- span, State.body_redactor, side: :client, direction: :request
75
- ) { headers(datum[:headers]) }
76
- capture&.observe(body)
77
- capture
73
+ BodyCapture.client_request(span, body) { BodyCapture.safe_headers(datum[:headers]) }
78
74
  end
79
75
 
80
76
  # Captures the parsed response body. A response handed to a response block never reaches
81
77
  # the datum, so it is skipped. When a middleware above owns the request, it is told
82
78
  # instead what Decompress is about to inflate.
83
- def capture_response(datum)
84
- span = BodyCapture.client_span
79
+ def capture_response(datum, span)
85
80
  body = datum.dig(:response, :body)
86
81
  return unless body.is_a?(String) && !datum.key?(:response_block)
87
82
  return report_inflating(datum, body) unless span
88
83
 
89
- capture = BodyCapture.start(
90
- span, State.body_redactor, side: :client, direction: :response
91
- ) { headers(datum[:response][:headers]) }
84
+ capture = BodyCapture.start(span, side: :client, direction: :response) do
85
+ BodyCapture.safe_headers(datum[:response][:headers])
86
+ end
92
87
  return unless capture
93
88
 
94
89
  capture.observe(body)
@@ -101,19 +96,12 @@ module Foam
101
96
  def report_inflating(datum, body)
102
97
  return unless datum[:middlewares].include?(::Excon::Middleware::Decompress)
103
98
 
104
- encoding = headers(datum[:response][:headers])["content-encoding"]&.split(",")&.last&.strip
105
- return unless DECOMPRESSED.include?(encoding&.downcase)
99
+ header = BodyCapture.safe_headers(datum[:response][:headers])["content-encoding"]
100
+ encoding = header&.split(",")&.last&.strip
101
+ return unless Constants::INFLATED_ENCODINGS.include?(encoding&.downcase)
106
102
 
107
103
  BodyCapture.inflated_below(encoding, body.bytesize)
108
104
  end
109
-
110
- # Matched without regard to case, since the request headers keep whatever case the app
111
- # wrote.
112
- def headers(hash)
113
- Constants::SAFE_NETWORK_HEADERS.to_h do |name|
114
- [name, hash.find { |key, _value| key.casecmp?(name) }&.last]
115
- end.compact
116
- end
117
105
  end
118
106
  end
119
107
  end