foam-otel 0.1.0
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 +7 -0
- data/README.md +76 -0
- data/lib/foam/otel/config.rb +66 -0
- data/lib/foam/otel/constants.rb +62 -0
- data/lib/foam/otel/errors.rb +54 -0
- data/lib/foam/otel/init.rb +351 -0
- data/lib/foam/otel/logger_bridge.rb +75 -0
- data/lib/foam/otel/rack.rb +243 -0
- data/lib/foam/otel/railtie.rb +66 -0
- data/lib/foam/otel/redaction.rb +68 -0
- data/lib/foam/otel/sidekiq.rb +88 -0
- data/lib/foam/otel/stamps.rb +104 -0
- data/lib/foam/otel/vendor/datadog.rb +35 -0
- data/lib/foam/otel/vendor/rollbar.rb +46 -0
- data/lib/foam/otel/version.rb +9 -0
- data/lib/foam/otel.rb +23 -0
- data/lib/foam-otel.rb +4 -0
- metadata +154 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8650474eaaa3dec06f5500839b073d5c2b211750da4ad2a068459cbbb5feb3f1
|
|
4
|
+
data.tar.gz: 6c7b40fe1a9297f0c81547222f4ea7a36c716a9bc1eb6a2f97c5e44d49f6b9eb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 39fd5531d0478acdff0ae4e36ae901ee3975a2b2c55e84319bba959683ac3fa69dd226639f2c1c7f4682eec9d064843071209f7ed56bf149d30591cecc732ba8
|
|
7
|
+
data.tar.gz: b32886c34c0b60fd1f7751a7cb7e1a81152804c7c495078217ef7e63a83025b15b5336acffad32ebc23e730c42120aa8e32519cd3a9d1154f049130b75ad8160
|
data/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# foam-otel
|
|
2
|
+
|
|
3
|
+
The shared foam OpenTelemetry core for Ruby services — traces, logs, and
|
|
4
|
+
metrics over OTLP HTTP, implementing the fleet wire contract
|
|
5
|
+
([`contract/SPEC.md`](../../contract/SPEC.md)). Verified end-to-end by the
|
|
6
|
+
`test-apps/ruby-rails` conformance gate on every PR.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Gemfile
|
|
10
|
+
gem "foam-otel"
|
|
11
|
+
|
|
12
|
+
# config/initializers/foam.rb (or before Rails loads)
|
|
13
|
+
Foam::Otel.init(service_name: "my-service")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That's the whole integration. Everything else self-wires by presence
|
|
17
|
+
detection (zero customer effort):
|
|
18
|
+
|
|
19
|
+
- **Rails**: railtie inserts the Rack middleware at position 0, captures
|
|
20
|
+
controller exceptions that `rescue_from` swallows (via
|
|
21
|
+
`process_action.action_controller`), and broadcasts `Rails.logger` lines to
|
|
22
|
+
foam as `log.rails` records — the app's own logger chain is never touched.
|
|
23
|
+
- **Sidekiq**: server middleware auto-registers; one `sidekiq.consume <queue>`
|
|
24
|
+
consumer span per job, failures re-raised identically (retries untouched).
|
|
25
|
+
- **Datadog** (SPEC §7.3): foam spans and logs carry `dd.trace_id`/`dd.span_id`
|
|
26
|
+
verbatim from the DD correlation API when a DD trace is active. Foam never
|
|
27
|
+
patches, parents from, or reconfigures the DD tracer.
|
|
28
|
+
- **Rollbar** (SPEC §12): exceptions the app reports to Rollbar (e.g. inside
|
|
29
|
+
`rescue_from` handlers) are also recorded on the active foam span — Rollbar
|
|
30
|
+
itself is untouched, and each exception records at most once per span.
|
|
31
|
+
|
|
32
|
+
| option | default | |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `service_name:` | (required) | resource `service.name` |
|
|
35
|
+
| `token:` | `FOAM_OTEL_TOKEN` env | missing → warn + inert |
|
|
36
|
+
| `endpoint:` | `OTEL_EXPORTER_OTLP_ENDPOINT` env → `https://otel.api.foam.ai` | |
|
|
37
|
+
| `force_export:` | `false` | export outside production |
|
|
38
|
+
| `redact_contact_info:` | `false` | append email/phone/telefono/celular to redaction |
|
|
39
|
+
| `redact_keys:` | `[]` | extra redacted keys |
|
|
40
|
+
| `health_routes:` | `[]` | routes exempted from request-context capture (none by default — health checks are captured) |
|
|
41
|
+
| `sidekiq:` / `rollbar:` / `rails:` | `true` | opt out of auto-wiring that adapter |
|
|
42
|
+
|
|
43
|
+
Export is production-gated (`RAILS_ENV`/`RACK_ENV`/`ENVIRONMENT`/`ENV`/
|
|
44
|
+
`NODE_ENV` ∈ {production, prod}) unless `force_export`. Batch cadence comes
|
|
45
|
+
from the standard OTel env vars (`OTEL_BSP_SCHEDULE_DELAY`,
|
|
46
|
+
`OTEL_BLRP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL`).
|
|
47
|
+
|
|
48
|
+
**Route names**: span names and `http.route` use Rails' route template
|
|
49
|
+
(`action_dispatch.route_uri_pattern`, e.g. `/users/:id`) when available. On
|
|
50
|
+
older Rails or bare Rack where it isn't populated, the raw path is used —
|
|
51
|
+
which raises span-name cardinality for routes with dynamic segments; prefer
|
|
52
|
+
Rails 7.1+ for automatic low-cardinality route names.
|
|
53
|
+
|
|
54
|
+
**Forked servers** (Puma workers, Sidekiq): span/log pipelines self-heal
|
|
55
|
+
after fork; the metrics reader is re-armed automatically by the first
|
|
56
|
+
request/job in the child via a pid check. Belt-and-braces:
|
|
57
|
+
`on_worker_boot { Foam::Otel.after_fork! }`.
|
|
58
|
+
|
|
59
|
+
Custom telemetry passthrough (stamped `app.custom` automatically). Create
|
|
60
|
+
each instrument ONCE and reuse it — creating an instrument per call
|
|
61
|
+
re-registers it under a mutex and logs a duplicate-registration warning:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Foam::Otel.tracer.in_span("work") { |span| ... }
|
|
65
|
+
|
|
66
|
+
JOBS_DONE = Foam::Otel.meter.create_counter("jobs_done") # once, at load
|
|
67
|
+
JOBS_DONE.add(1) # per event
|
|
68
|
+
|
|
69
|
+
Foam::Otel.logger.on_emit(severity_number: 9, body: "hi", context: OpenTelemetry::Context.current)
|
|
70
|
+
Foam::Otel.record_exception(error) # foam span only; never notifies a tracker
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bundle exec rspec # unit suite
|
|
75
|
+
bash ../../test-apps/ruby-rails/run-verify.sh # full conformance gate
|
|
76
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "constants"
|
|
6
|
+
|
|
7
|
+
module Foam
|
|
8
|
+
module Otel
|
|
9
|
+
# Resolved configuration — options + env vars, per contract/SPEC.md:
|
|
10
|
+
# endpoint is option > OTEL_EXPORTER_OTLP_ENDPOINT > default; batch
|
|
11
|
+
# cadence comes from the standard OTel env vars, which the Ruby SDK reads
|
|
12
|
+
# natively (never hardcoded here).
|
|
13
|
+
Config = Struct.new(:service_name, :endpoint, :redacted_keys, :health_routes, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = Config.new(
|
|
16
|
+
service_name: "",
|
|
17
|
+
endpoint: FOAM_OTEL_ENDPOINT,
|
|
18
|
+
redacted_keys: DEFAULT_REDACTED_KEYS.to_set.freeze,
|
|
19
|
+
health_routes: DEFAULT_HEALTH_ROUTES.to_set.freeze
|
|
20
|
+
).freeze
|
|
21
|
+
|
|
22
|
+
@active_config = DEFAULT_CONFIG
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# The config of the most recently initialized service. Adapters read
|
|
26
|
+
# through this so they work (with safe defaults) even before init().
|
|
27
|
+
def active_config
|
|
28
|
+
@active_config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def active_config=(config)
|
|
32
|
+
@active_config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve_endpoint(endpoint)
|
|
36
|
+
resolved = endpoint || ENV.fetch("OTEL_EXPORTER_OTLP_ENDPOINT", nil) || FOAM_OTEL_ENDPOINT
|
|
37
|
+
resolved.sub(%r{/+\z}, "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_config(service_name:, endpoint: nil, redact_contact_info: false,
|
|
41
|
+
redact_keys: nil, health_routes: nil)
|
|
42
|
+
keys = DEFAULT_REDACTED_KEYS.to_set
|
|
43
|
+
keys.merge(CONTACT_INFO_KEYS) if redact_contact_info
|
|
44
|
+
(redact_keys || []).each { |key| keys.add(key.downcase) }
|
|
45
|
+
|
|
46
|
+
routes = DEFAULT_HEALTH_ROUTES.to_set
|
|
47
|
+
(health_routes || []).each { |route| routes.add(route) }
|
|
48
|
+
|
|
49
|
+
Config.new(
|
|
50
|
+
service_name: service_name,
|
|
51
|
+
endpoint: resolve_endpoint(endpoint),
|
|
52
|
+
redacted_keys: keys.freeze,
|
|
53
|
+
health_routes: routes.freeze
|
|
54
|
+
).freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Production detection across common conventions (RAILS_ENV/RACK_ENV
|
|
58
|
+
# first — the Ruby idioms — then the fleet-shared names).
|
|
59
|
+
def production?
|
|
60
|
+
%w[RAILS_ENV RACK_ENV ENVIRONMENT ENV NODE_ENV].any? do |var|
|
|
61
|
+
%w[production prod].include?(ENV.fetch(var, "").downcase)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foam
|
|
4
|
+
module Otel
|
|
5
|
+
# Fleet-wide defaults. The canonical source is contract/defaults.json;
|
|
6
|
+
# contract/verify/check-defaults.mjs extracts the literals below from THIS
|
|
7
|
+
# file on every conformance run — keep them flat quoted-string array
|
|
8
|
+
# literals (no %w[], no computed values) or extraction breaks.
|
|
9
|
+
|
|
10
|
+
FOAM_OTEL_ENDPOINT = "https://otel.api.foam.ai"
|
|
11
|
+
|
|
12
|
+
BODY_TRUNCATION_LIMIT = 4096
|
|
13
|
+
MAX_REDACT_DEPTH = 10
|
|
14
|
+
|
|
15
|
+
DEFAULT_REDACTED_KEYS = [
|
|
16
|
+
"password",
|
|
17
|
+
"token",
|
|
18
|
+
"authorization",
|
|
19
|
+
"secret",
|
|
20
|
+
"api_key",
|
|
21
|
+
"apikey",
|
|
22
|
+
"access_token",
|
|
23
|
+
"accesstoken",
|
|
24
|
+
"jwt",
|
|
25
|
+
"x-api-key",
|
|
26
|
+
"x-service-token",
|
|
27
|
+
"cookie",
|
|
28
|
+
"set-cookie",
|
|
29
|
+
"creditcard",
|
|
30
|
+
"credit_card",
|
|
31
|
+
"card_number",
|
|
32
|
+
"cardnumber",
|
|
33
|
+
"cvv",
|
|
34
|
+
"cvc",
|
|
35
|
+
"dni",
|
|
36
|
+
"ssn",
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Opt-in contact-PII list. Enabled via `redact_contact_info: true`.
|
|
40
|
+
CONTACT_INFO_KEYS = ["email", "phone", "telefono", "celular"].freeze
|
|
41
|
+
|
|
42
|
+
# Empty by default: health checks are captured like any other route
|
|
43
|
+
# (contract SPEC.md section 5); services exempt routes via health_routes.
|
|
44
|
+
DEFAULT_HEALTH_ROUTES = [].freeze
|
|
45
|
+
|
|
46
|
+
REQUEST_ID_HEADER = "x-request-id"
|
|
47
|
+
|
|
48
|
+
INSTRUMENTATION_KEY = "foam.instrumentation"
|
|
49
|
+
RID_KEY = "foam.rid"
|
|
50
|
+
|
|
51
|
+
module STAMPS
|
|
52
|
+
INBOUND_HTTP = "inbound.http"
|
|
53
|
+
OUTBOUND_HTTP = "outbound.http"
|
|
54
|
+
SQS_CONSUME = "sqs.consume"
|
|
55
|
+
SIDEKIQ_CONSUME = "sidekiq.consume"
|
|
56
|
+
QUEUE_PRODUCE = "queue.produce"
|
|
57
|
+
LOG_RAILS = "log.rails"
|
|
58
|
+
LOG_CONSOLE = "log.console"
|
|
59
|
+
APP_CUSTOM = "app.custom"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foam
|
|
4
|
+
module Otel
|
|
5
|
+
# One exception, one event (contract SPEC.md section 12): every capture
|
|
6
|
+
# path — the Rack middleware's unhandled-raise rescue, the railtie's
|
|
7
|
+
# process_action notification hook, the error-tracker bridge, and the
|
|
8
|
+
# public record_exception API — records through here, deduped per span by
|
|
9
|
+
# exception object identity.
|
|
10
|
+
#
|
|
11
|
+
# The seen-set lives ON the span object (an instance variable), not in a
|
|
12
|
+
# thread-local: a request span can be recorded on one thread and finished
|
|
13
|
+
# on another (ActionController::Live streams the response body from a
|
|
14
|
+
# separate thread), so a thread-local would both miss the dedupe/recorded
|
|
15
|
+
# check across threads and leak entries for spans that never pass back
|
|
16
|
+
# through their owning thread. Span-carried state is GC'd with the span
|
|
17
|
+
# and travels with it across threads.
|
|
18
|
+
module Errors
|
|
19
|
+
IVAR = :@__foam_otel_recorded
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Records `error` on `span` (exception event + status ERROR) unless this
|
|
24
|
+
# exact exception object was already recorded there. Never raises.
|
|
25
|
+
def record_once(span, error)
|
|
26
|
+
return false unless span.respond_to?(:context) && span.context.valid?
|
|
27
|
+
return false unless span.respond_to?(:recording?) && span.recording?
|
|
28
|
+
|
|
29
|
+
seen = span.instance_variable_get(IVAR)
|
|
30
|
+
unless seen
|
|
31
|
+
seen = []
|
|
32
|
+
span.instance_variable_set(IVAR, seen)
|
|
33
|
+
end
|
|
34
|
+
return false if seen.include?(error.object_id)
|
|
35
|
+
|
|
36
|
+
seen << error.object_id
|
|
37
|
+
span.record_exception(error)
|
|
38
|
+
span.status = OpenTelemetry::Trace::Status.error(error.message.to_s)
|
|
39
|
+
true
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Whether any exception has been recorded on this span (so a later OK
|
|
45
|
+
# status never clobbers a thrown-then-mapped ERROR — SPEC section 6).
|
|
46
|
+
def recorded?(span)
|
|
47
|
+
seen = span.instance_variable_get(IVAR)
|
|
48
|
+
!seen.nil? && !seen.empty?
|
|
49
|
+
rescue StandardError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "opentelemetry-logs-sdk"
|
|
5
|
+
require "opentelemetry-metrics-sdk"
|
|
6
|
+
require "opentelemetry-exporter-otlp"
|
|
7
|
+
require "opentelemetry-exporter-otlp-logs"
|
|
8
|
+
require "opentelemetry-exporter-otlp-metrics"
|
|
9
|
+
|
|
10
|
+
require_relative "config"
|
|
11
|
+
require_relative "stamps"
|
|
12
|
+
require_relative "errors"
|
|
13
|
+
require_relative "vendor/rollbar"
|
|
14
|
+
|
|
15
|
+
module Foam
|
|
16
|
+
module Otel
|
|
17
|
+
# Handle returned by init(). Providers are global — this mostly carries
|
|
18
|
+
# the resolved config and shutdown.
|
|
19
|
+
class Instance
|
|
20
|
+
attr_reader :service_name, :config
|
|
21
|
+
attr_accessor :providers
|
|
22
|
+
|
|
23
|
+
def initialize(service_name, config)
|
|
24
|
+
@service_name = service_name
|
|
25
|
+
@config = config
|
|
26
|
+
@providers = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def shutdown
|
|
30
|
+
if @providers.empty?
|
|
31
|
+
# This instance owns no providers (inert, or a later init whose
|
|
32
|
+
# signals were already wired). It must not tear down the owner's
|
|
33
|
+
# pipelines, but — flush parity — it still best-effort flushes
|
|
34
|
+
# foam's live pipelines so buffered telemetry isn't dropped.
|
|
35
|
+
Foam::Otel.flush_foam_pipelines
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
@providers.each do |provider|
|
|
39
|
+
begin
|
|
40
|
+
provider.shutdown
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
Foam::Otel.forget_provider(provider)
|
|
45
|
+
end
|
|
46
|
+
Foam::Otel.clear_fallback_meter_provider_if_in(@providers)
|
|
47
|
+
@providers = []
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@instances = {}
|
|
52
|
+
# Providers foam itself created AND registered globally. A second init()
|
|
53
|
+
# must not attach another stamp+batch pipeline to them (double export) and
|
|
54
|
+
# must not misclassify them as "another SDK's" provider.
|
|
55
|
+
@foam_providers = []
|
|
56
|
+
# Foreign providers foam already attached its stamp+batch pipeline to.
|
|
57
|
+
@attached_providers = []
|
|
58
|
+
# Live flushable pipelines foam owns (providers foam created + batch
|
|
59
|
+
# processors foam attached to foreign providers).
|
|
60
|
+
@flushables = []
|
|
61
|
+
# Metrics-coexistence fallback (SPEC section 9): when another SDK owns the
|
|
62
|
+
# global MeterProvider, foam's provider lives here and meter() serves it.
|
|
63
|
+
@fallback_meter_provider = nil
|
|
64
|
+
# Foam-owned periodic metric readers — the one pipeline piece that does
|
|
65
|
+
# NOT self-heal across forks (batch span/log processors reset_on_fork
|
|
66
|
+
# automatically; verified upstream) and needs an explicit after_fork.
|
|
67
|
+
@metric_readers = []
|
|
68
|
+
# PID at last check_fork! — re-arms metric readers once per fork.
|
|
69
|
+
@pid = Process.pid
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# rubocop:disable Metrics/MethodLength
|
|
73
|
+
def init(service_name:, token: nil, endpoint: nil, force_export: false,
|
|
74
|
+
redact_contact_info: false, redact_keys: nil, health_routes: nil,
|
|
75
|
+
sidekiq: true, rollbar: true, rails: true)
|
|
76
|
+
existing = @instances[service_name]
|
|
77
|
+
if existing
|
|
78
|
+
warn "[foam] init() already called for \"#{service_name}\" — returning existing instance"
|
|
79
|
+
return existing
|
|
80
|
+
end
|
|
81
|
+
unless @instances.empty?
|
|
82
|
+
warn "[foam] init(\"#{service_name}\") called after #{@instances.keys.sort} — " \
|
|
83
|
+
"the active adapter config is process-global and last-write-wins"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The resolved config becomes the active config on EVERY path — even
|
|
87
|
+
# inert init: another SDK may own the global providers and the
|
|
88
|
+
# adapters still emit real spans through it.
|
|
89
|
+
config = resolve_config(
|
|
90
|
+
service_name: service_name, endpoint: endpoint,
|
|
91
|
+
redact_contact_info: redact_contact_info, redact_keys: redact_keys,
|
|
92
|
+
health_routes: health_routes
|
|
93
|
+
)
|
|
94
|
+
self.active_config = config
|
|
95
|
+
|
|
96
|
+
# Zero-customer-effort: adapters and vendor bridges self-wire by
|
|
97
|
+
# presence detection on every init path (inert included — a foreign
|
|
98
|
+
# SDK may still export what the adapters produce). Opt-outs only.
|
|
99
|
+
auto_wire(sidekiq: sidekiq, rollbar: rollbar, rails: rails)
|
|
100
|
+
|
|
101
|
+
unless production? || force_export
|
|
102
|
+
warn "[foam] skipping init for \"#{service_name}\" (not production, force_export false)"
|
|
103
|
+
return remember(Instance.new(service_name, config))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
resolved_token = token || ENV.fetch("FOAM_OTEL_TOKEN", nil)
|
|
107
|
+
if resolved_token.nil? || resolved_token.empty?
|
|
108
|
+
warn "[foam] token is missing — telemetry will not be exported. " \
|
|
109
|
+
"Set token or the FOAM_OTEL_TOKEN env var."
|
|
110
|
+
return remember(Instance.new(service_name, config))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
instance = Instance.new(service_name, config)
|
|
114
|
+
headers = { "Authorization" => "Bearer #{resolved_token}" }
|
|
115
|
+
resource = OpenTelemetry::SDK::Resources::Resource.create("service.name" => service_name)
|
|
116
|
+
|
|
117
|
+
# We bypass OpenTelemetry::SDK.configure, so propagation must be set
|
|
118
|
+
# explicitly: W3C TraceContext + Baggage.
|
|
119
|
+
OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator
|
|
120
|
+
.compose_propagators([
|
|
121
|
+
OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator,
|
|
122
|
+
OpenTelemetry::Baggage::Propagation.text_map_propagator,
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
# Each signal is independent — a failure in one never blocks the rest.
|
|
126
|
+
%i[setup_traces setup_logs setup_metrics].each do |setup|
|
|
127
|
+
provider = send(setup, config.endpoint, headers, resource)
|
|
128
|
+
instance.providers << provider if provider
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
warn "[foam] #{setup} failed: #{e.class}: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
at_exit { instance.shutdown }
|
|
134
|
+
warn "[foam] OTLP export enabled for #{service_name} -> #{config.endpoint}"
|
|
135
|
+
remember(instance)
|
|
136
|
+
end
|
|
137
|
+
# rubocop:enable Metrics/MethodLength
|
|
138
|
+
|
|
139
|
+
# Signal passthrough: a tracer/meter/logger that exports to foam after
|
|
140
|
+
# init(). meter() serves the fallback provider when another SDK owns the
|
|
141
|
+
# global MeterProvider (SPEC section 9).
|
|
142
|
+
def tracer(name = "foam")
|
|
143
|
+
OpenTelemetry.tracer_provider.tracer(name)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def meter(name = "foam")
|
|
147
|
+
return @fallback_meter_provider.meter(name) if @fallback_meter_provider
|
|
148
|
+
|
|
149
|
+
OpenTelemetry.meter_provider.meter(name)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def logger(name = "foam")
|
|
153
|
+
OpenTelemetry.logger_provider.logger(name: name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Records an exception on the active span (status ERROR) when one
|
|
157
|
+
# exists. Records to the foam span ONLY — never notifies any error
|
|
158
|
+
# tracker (SPEC section 12). Never raises.
|
|
159
|
+
def record_exception(error)
|
|
160
|
+
Errors.record_once(OpenTelemetry::Trace.current_span, error)
|
|
161
|
+
nil
|
|
162
|
+
rescue StandardError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Metrics readers do not self-heal across forks (unlike the batch
|
|
167
|
+
# span/log processors, which reset_on_fork automatically). Call in
|
|
168
|
+
# Puma's on_worker_boot / Sidekiq lifecycle — the Rack and Sidekiq
|
|
169
|
+
# adapters also invoke it via check_fork! per call.
|
|
170
|
+
def after_fork!
|
|
171
|
+
@metric_readers.each do |reader|
|
|
172
|
+
reader.after_fork if reader.respond_to?(:after_fork)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One authoritative process-level fork check the adapters call per
|
|
179
|
+
# request/job — re-arms the metric readers exactly once per fork.
|
|
180
|
+
def check_fork!
|
|
181
|
+
pid = Process.pid
|
|
182
|
+
return if @pid == pid
|
|
183
|
+
|
|
184
|
+
@pid = pid
|
|
185
|
+
after_fork!
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Whether Rails adapter hooks (middleware / exception notification /
|
|
189
|
+
# logger bridge) should activate. False until an init() with rails:true
|
|
190
|
+
# runs, so merely bundling the gem never installs global Rails hooks —
|
|
191
|
+
# the railtie checks this in after_initialize (opt-out symmetry with
|
|
192
|
+
# sidekiq:/rollbar:).
|
|
193
|
+
def rails_enabled?
|
|
194
|
+
@rails_enabled == true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- registries (used by Instance#shutdown and the pipelines) ---------
|
|
198
|
+
|
|
199
|
+
def flush_foam_pipelines
|
|
200
|
+
flushables = @flushables.dup
|
|
201
|
+
flushables << @fallback_meter_provider if @fallback_meter_provider
|
|
202
|
+
flushables.each do |flushable|
|
|
203
|
+
flushable.force_flush
|
|
204
|
+
rescue StandardError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def forget_provider(provider)
|
|
210
|
+
@foam_providers.delete_if { |p| p.equal?(provider) }
|
|
211
|
+
@flushables.delete_if { |p| p.equal?(provider) }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def clear_fallback_meter_provider_if_in(providers)
|
|
215
|
+
return unless @fallback_meter_provider
|
|
216
|
+
|
|
217
|
+
@fallback_meter_provider = nil if providers.any? { |p| p.equal?(@fallback_meter_provider) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Test hook: full reset of module state (NOT for production use — OTel
|
|
221
|
+
# globals cannot be re-registered after a real shutdown).
|
|
222
|
+
def reset_for_tests!
|
|
223
|
+
@instances = {}
|
|
224
|
+
@foam_providers = []
|
|
225
|
+
@attached_providers = []
|
|
226
|
+
@flushables = []
|
|
227
|
+
@fallback_meter_provider = nil
|
|
228
|
+
@metric_readers = []
|
|
229
|
+
@pid = Process.pid
|
|
230
|
+
@rails_enabled = false
|
|
231
|
+
self.active_config = DEFAULT_CONFIG
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def remember(instance)
|
|
237
|
+
@instances[instance.service_name] = instance
|
|
238
|
+
instance
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def foam_provider?(provider)
|
|
242
|
+
@foam_providers.any? { |p| p.equal?(provider) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def attached?(provider)
|
|
246
|
+
@attached_providers.any? { |p| p.equal?(provider) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def auto_wire(sidekiq:, rollbar:, rails:)
|
|
250
|
+
# install! calls are individually idempotent, so a later init() can
|
|
251
|
+
# still wire an integration an earlier one opted out of.
|
|
252
|
+
@rails_enabled ||= rails
|
|
253
|
+
Vendor::Rollbar.install! if rollbar
|
|
254
|
+
if sidekiq && defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
255
|
+
require_relative "sidekiq"
|
|
256
|
+
Foam::Otel::Sidekiq.install!
|
|
257
|
+
end
|
|
258
|
+
# Rails hooks activate lazily in the railtie's after_initialize once
|
|
259
|
+
# rails_enabled? is true (see railtie.rb).
|
|
260
|
+
rescue StandardError => e
|
|
261
|
+
warn "[foam] adapter auto-wiring failed: #{e.class}: #{e.message}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def setup_traces(endpoint, headers, resource)
|
|
265
|
+
current = OpenTelemetry.tracer_provider
|
|
266
|
+
return nil if foam_provider?(current)
|
|
267
|
+
|
|
268
|
+
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
269
|
+
endpoint: "#{endpoint}/v1/traces", headers: headers
|
|
270
|
+
)
|
|
271
|
+
if current.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
|
|
272
|
+
return nil if attached?(current)
|
|
273
|
+
|
|
274
|
+
# Another SDK registered a real provider — attach foam's pipeline to
|
|
275
|
+
# it rather than displacing it (SPEC section 9).
|
|
276
|
+
current.add_span_processor(StampSpanProcessor.new)
|
|
277
|
+
batch = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
|
|
278
|
+
current.add_span_processor(batch)
|
|
279
|
+
@attached_providers << current
|
|
280
|
+
@flushables << batch
|
|
281
|
+
return batch
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
|
|
285
|
+
provider.add_span_processor(StampSpanProcessor.new)
|
|
286
|
+
provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
|
|
287
|
+
OpenTelemetry.tracer_provider = provider
|
|
288
|
+
@foam_providers << provider
|
|
289
|
+
@flushables << provider
|
|
290
|
+
provider
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def setup_logs(endpoint, headers, resource)
|
|
294
|
+
current = OpenTelemetry.logger_provider
|
|
295
|
+
return nil if foam_provider?(current)
|
|
296
|
+
|
|
297
|
+
exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
|
|
298
|
+
endpoint: "#{endpoint}/v1/logs", headers: headers
|
|
299
|
+
)
|
|
300
|
+
if current.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
|
|
301
|
+
return nil if attached?(current)
|
|
302
|
+
|
|
303
|
+
current.add_log_record_processor(StampLogRecordProcessor.new)
|
|
304
|
+
batch = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
|
|
305
|
+
current.add_log_record_processor(batch)
|
|
306
|
+
@attached_providers << current
|
|
307
|
+
@flushables << batch
|
|
308
|
+
return batch
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
|
|
312
|
+
provider.add_log_record_processor(StampLogRecordProcessor.new)
|
|
313
|
+
provider.add_log_record_processor(OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter))
|
|
314
|
+
OpenTelemetry.logger_provider = provider
|
|
315
|
+
@foam_providers << provider
|
|
316
|
+
@flushables << provider
|
|
317
|
+
provider
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def setup_metrics(endpoint, headers, resource)
|
|
321
|
+
current = OpenTelemetry.meter_provider
|
|
322
|
+
return nil if foam_provider?(current)
|
|
323
|
+
return nil if @fallback_meter_provider
|
|
324
|
+
|
|
325
|
+
exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
|
|
326
|
+
endpoint: "#{endpoint}/v1/metrics", headers: headers
|
|
327
|
+
)
|
|
328
|
+
reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter)
|
|
329
|
+
provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
|
|
330
|
+
provider.add_metric_reader(reader)
|
|
331
|
+
@metric_readers << reader
|
|
332
|
+
|
|
333
|
+
# Ruby's global meter_provider setter does not refuse replacement the
|
|
334
|
+
# way Python's does — so foam refuses for it: when another real SDK
|
|
335
|
+
# MeterProvider owns the global, keep foam's as the fallback served by
|
|
336
|
+
# Foam::Otel.meter (SPEC section 9) instead of displacing theirs.
|
|
337
|
+
if current.is_a?(OpenTelemetry::SDK::Metrics::MeterProvider)
|
|
338
|
+
@fallback_meter_provider = provider
|
|
339
|
+
warn "[foam] another MeterProvider is already registered globally — " \
|
|
340
|
+
"foam metrics are available through Foam::Otel.meter only; " \
|
|
341
|
+
"metrics created via the OTel metrics API will not export to foam"
|
|
342
|
+
else
|
|
343
|
+
OpenTelemetry.meter_provider = provider
|
|
344
|
+
@foam_providers << provider
|
|
345
|
+
@flushables << provider
|
|
346
|
+
end
|
|
347
|
+
provider
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
require_relative "constants"
|
|
6
|
+
|
|
7
|
+
module Foam
|
|
8
|
+
module Otel
|
|
9
|
+
# Rails.logger bridge — a broadcast target (Rails 7.1+ BroadcastLogger)
|
|
10
|
+
# that re-emits every log line as an OTel log record stamped log.rails.
|
|
11
|
+
# Attached via `Rails.logger.broadcast_to(bridge)` so the app's own logger
|
|
12
|
+
# chain (devices, formatters, TaggedLogging tags) is never wrapped or
|
|
13
|
+
# altered; trace correlation comes from the active context through the
|
|
14
|
+
# stamp/batch pipeline.
|
|
15
|
+
class LoggerBridge < ::Logger
|
|
16
|
+
SEVERITY_TEXT = { 0 => "DEBUG", 1 => "INFO", 2 => "WARN", 3 => "ERROR", 4 => "FATAL", 5 => "UNKNOWN" }.freeze
|
|
17
|
+
# OTel severity numbers: DEBUG 5, INFO 9, WARN 13, ERROR 17, FATAL 21.
|
|
18
|
+
SEVERITY_NUMBER = { 0 => 5, 1 => 9, 2 => 13, 3 => 17, 4 => 21, 5 => 1 }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(tagged_source: nil)
|
|
21
|
+
super(nil) # no device — emission goes to the OTel logs pipeline
|
|
22
|
+
@tagged_source = tagged_source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Memoized so the hot per-line path is an ivar read, not a
|
|
26
|
+
# LoggerProvider#logger call (which takes a registry mutex + allocates
|
|
27
|
+
# a lookup key on every call).
|
|
28
|
+
def otel_logger
|
|
29
|
+
@otel_logger ||= Foam::Otel.logger("foam-otel/rails")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
33
|
+
# The bridge is attached unconditionally by the railtie; it stays
|
|
34
|
+
# inert until init(rails: true) and honors rails: false.
|
|
35
|
+
return true unless Foam::Otel.rails_enabled?
|
|
36
|
+
|
|
37
|
+
severity ||= ::Logger::UNKNOWN
|
|
38
|
+
return true if severity < level
|
|
39
|
+
|
|
40
|
+
body = message || (block ? yield : progname)
|
|
41
|
+
return true if body.nil?
|
|
42
|
+
|
|
43
|
+
attributes = { INSTRUMENTATION_KEY => STAMPS::LOG_RAILS }
|
|
44
|
+
tags = current_tags
|
|
45
|
+
attributes["rails.tags"] = tags.join(" ") unless tags.empty?
|
|
46
|
+
|
|
47
|
+
otel_logger.on_emit(
|
|
48
|
+
severity_text: SEVERITY_TEXT.fetch(severity, "UNKNOWN"),
|
|
49
|
+
severity_number: SEVERITY_NUMBER.fetch(severity, 1),
|
|
50
|
+
timestamp: Time.now,
|
|
51
|
+
body: body.to_s,
|
|
52
|
+
attributes: attributes,
|
|
53
|
+
context: OpenTelemetry::Context.current
|
|
54
|
+
)
|
|
55
|
+
true
|
|
56
|
+
rescue StandardError
|
|
57
|
+
true # the bridge must never break application logging
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# TaggedLogging tags (e.g. the request_id from config.log_tags) live on
|
|
63
|
+
# the SOURCE logger's formatter; the bridge reads them without touching
|
|
64
|
+
# the source chain.
|
|
65
|
+
def current_tags
|
|
66
|
+
formatter = @tagged_source&.formatter
|
|
67
|
+
return [] unless formatter.respond_to?(:current_tags)
|
|
68
|
+
|
|
69
|
+
Array(formatter.current_tags).map(&:to_s)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|