Module: Foam::Otel

Defined in:
lib/foam/otel/init.rb,
lib/foam/otel/rack.rb,
lib/foam/otel/config.rb,
lib/foam/otel/errors.rb,
lib/foam/otel/stamps.rb,
lib/foam/otel/railtie.rb,
lib/foam/otel/sidekiq.rb,
lib/foam/otel/version.rb,
lib/foam/otel/constants.rb,
lib/foam/otel/redaction.rb,
lib/foam/otel/logger_bridge.rb,
lib/foam/otel/vendor/datadog.rb,
lib/foam/otel/vendor/rollbar.rb

Defined Under Namespace

Modules: Errors, Rack, Redaction, STAMPS, Sidekiq, Stamping, Vendor Classes: Config, Instance, LoggerBridge, Railtie, StampLogRecordProcessor, StampSpanProcessor

Constant Summary collapse

DEFAULT_CONFIG =
Config.new(
  service_name: "",
  endpoint: FOAM_OTEL_ENDPOINT,
  redacted_keys: DEFAULT_REDACTED_KEYS.to_set.freeze,
  health_routes: DEFAULT_HEALTH_ROUTES.to_set.freeze
).freeze
VERSION =

The publish pipeline parses this literal (tools/publish-packages) — strict semver only; gem-style prereleases (0.1.0.alpha1) are unsupported.

"0.1.0"
FOAM_OTEL_ENDPOINT =

Fleet-wide defaults. The canonical source is contract/defaults.json; contract/verify/check-defaults.mjs extracts the literals below from THIS file on every conformance run — keep them flat quoted-string array literals (no %w, no computed values) or extraction breaks.

"https://otel.api.foam.ai"
BODY_TRUNCATION_LIMIT =
4096
MAX_REDACT_DEPTH =
10
DEFAULT_REDACTED_KEYS =
[
  "password",
  "token",
  "authorization",
  "secret",
  "api_key",
  "apikey",
  "access_token",
  "accesstoken",
  "jwt",
  "x-api-key",
  "x-service-token",
  "cookie",
  "set-cookie",
  "creditcard",
  "credit_card",
  "card_number",
  "cardnumber",
  "cvv",
  "cvc",
  "dni",
  "ssn",
].freeze
CONTACT_INFO_KEYS =

Opt-in contact-PII list. Enabled via redact_contact_info: true.

["email", "phone", "telefono", "celular"].freeze
DEFAULT_HEALTH_ROUTES =

Empty by default: health checks are captured like any other route (contract SPEC.md section 5); services exempt routes via health_routes.

[].freeze
REQUEST_ID_HEADER =
"x-request-id"
INSTRUMENTATION_KEY =
"foam.instrumentation"
RID_KEY =
"foam.rid"

Class Method Summary collapse

Class Method Details

.active_configObject

The config of the most recently initialized service. Adapters read through this so they work (with safe defaults) even before init().



27
28
29
# File 'lib/foam/otel/config.rb', line 27

def active_config
  @active_config
end

.active_config=(config) ⇒ Object



31
32
33
# File 'lib/foam/otel/config.rb', line 31

def active_config=(config)
  @active_config = config
end

.after_fork!Object

Metrics readers do not self-heal across forks (unlike the batch span/log processors, which reset_on_fork automatically). Call in Puma's on_worker_boot / Sidekiq lifecycle — the Rack and Sidekiq adapters also invoke it via check_fork! per call.



170
171
172
173
174
175
176
# File 'lib/foam/otel/init.rb', line 170

def after_fork!
  @metric_readers.each do |reader|
    reader.after_fork if reader.respond_to?(:after_fork)
  rescue StandardError
    nil
  end
end

.check_fork!Object

One authoritative process-level fork check the adapters call per request/job — re-arms the metric readers exactly once per fork.



180
181
182
183
184
185
186
# File 'lib/foam/otel/init.rb', line 180

def check_fork!
  pid = Process.pid
  return if @pid == pid

  @pid = pid
  after_fork!
end

.clear_fallback_meter_provider_if_in(providers) ⇒ Object



214
215
216
217
218
# File 'lib/foam/otel/init.rb', line 214

def clear_fallback_meter_provider_if_in(providers)
  return unless @fallback_meter_provider

  @fallback_meter_provider = nil if providers.any? { |p| p.equal?(@fallback_meter_provider) }
end

.flush_foam_pipelinesObject

--- registries (used by Instance#shutdown and the pipelines) ---------



199
200
201
202
203
204
205
206
207
# File 'lib/foam/otel/init.rb', line 199

def flush_foam_pipelines
  flushables = @flushables.dup
  flushables << @fallback_meter_provider if @fallback_meter_provider
  flushables.each do |flushable|
    flushable.force_flush
  rescue StandardError
    nil
  end
end

.forget_provider(provider) ⇒ Object



209
210
211
212
# File 'lib/foam/otel/init.rb', line 209

def forget_provider(provider)
  @foam_providers.delete_if { |p| p.equal?(provider) }
  @flushables.delete_if { |p| p.equal?(provider) }
end

.init(service_name:, token: nil, endpoint: nil, force_export: false, redact_contact_info: false, redact_keys: nil, health_routes: nil, sidekiq: true, rollbar: true, rails: true) ⇒ Object

rubocop:disable Metrics/MethodLength



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/foam/otel/init.rb', line 73

def init(service_name:, token: nil, endpoint: nil, force_export: false,
         redact_contact_info: false, redact_keys: nil, health_routes: nil,
         sidekiq: true, rollbar: true, rails: true)
  existing = @instances[service_name]
  if existing
    warn "[foam] init() already called for \"#{service_name}\" — returning existing instance"
    return existing
  end
  unless @instances.empty?
    warn "[foam] init(\"#{service_name}\") called after #{@instances.keys.sort}" \
         "the active adapter config is process-global and last-write-wins"
  end

  # The resolved config becomes the active config on EVERY path — even
  # inert init: another SDK may own the global providers and the
  # adapters still emit real spans through it.
  config = resolve_config(
    service_name: service_name, endpoint: endpoint,
    redact_contact_info: redact_contact_info, redact_keys: redact_keys,
    health_routes: health_routes
  )
  self.active_config = config

  # Zero-customer-effort: adapters and vendor bridges self-wire by
  # presence detection on every init path (inert included — a foreign
  # SDK may still export what the adapters produce). Opt-outs only.
  auto_wire(sidekiq: sidekiq, rollbar: rollbar, rails: rails)

  unless production? || force_export
    warn "[foam] skipping init for \"#{service_name}\" (not production, force_export false)"
    return remember(Instance.new(service_name, config))
  end

  resolved_token = token || ENV.fetch("FOAM_OTEL_TOKEN", nil)
  if resolved_token.nil? || resolved_token.empty?
    warn "[foam] token is missing — telemetry will not be exported. " \
         "Set token or the FOAM_OTEL_TOKEN env var."
    return remember(Instance.new(service_name, config))
  end

  instance = Instance.new(service_name, config)
  headers = { "Authorization" => "Bearer #{resolved_token}" }
  resource = OpenTelemetry::SDK::Resources::Resource.create("service.name" => service_name)

  # We bypass OpenTelemetry::SDK.configure, so propagation must be set
  # explicitly: W3C TraceContext + Baggage.
  OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator
    .compose_propagators([
      OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator,
      OpenTelemetry::Baggage::Propagation.text_map_propagator,
    ])

  # Each signal is independent — a failure in one never blocks the rest.
  %i[setup_traces setup_logs setup_metrics].each do |setup|
    provider = send(setup, config.endpoint, headers, resource)
    instance.providers << provider if provider
  rescue StandardError => e
    warn "[foam] #{setup} failed: #{e.class}: #{e.message}"
  end

  at_exit { instance.shutdown }
  warn "[foam] OTLP export enabled for #{service_name} -> #{config.endpoint}"
  remember(instance)
end

.logger(name = "foam") ⇒ Object



152
153
154
# File 'lib/foam/otel/init.rb', line 152

def logger(name = "foam")
  OpenTelemetry.logger_provider.logger(name: name)
end

.meter(name = "foam") ⇒ Object



146
147
148
149
150
# File 'lib/foam/otel/init.rb', line 146

def meter(name = "foam")
  return @fallback_meter_provider.meter(name) if @fallback_meter_provider

  OpenTelemetry.meter_provider.meter(name)
end

.production?Boolean

Production detection across common conventions (RAILS_ENV/RACK_ENV first — the Ruby idioms — then the fleet-shared names).

Returns:

  • (Boolean)


59
60
61
62
63
# File 'lib/foam/otel/config.rb', line 59

def production?
  %w[RAILS_ENV RACK_ENV ENVIRONMENT ENV NODE_ENV].any? do |var|
    %w[production prod].include?(ENV.fetch(var, "").downcase)
  end
end

.rails_enabled?Boolean

Whether Rails adapter hooks (middleware / exception notification / logger bridge) should activate. False until an init() with rails:true runs, so merely bundling the gem never installs global Rails hooks — the railtie checks this in after_initialize (opt-out symmetry with sidekiq:/rollbar:).

Returns:

  • (Boolean)


193
194
195
# File 'lib/foam/otel/init.rb', line 193

def rails_enabled?
  @rails_enabled == true
end

.record_exception(error) ⇒ Object

Records an exception on the active span (status ERROR) when one exists. Records to the foam span ONLY — never notifies any error tracker (SPEC section 12). Never raises.



159
160
161
162
163
164
# File 'lib/foam/otel/init.rb', line 159

def record_exception(error)
  Errors.record_once(OpenTelemetry::Trace.current_span, error)
  nil
rescue StandardError
  nil
end

.reset_for_tests!Object

Test hook: full reset of module state (NOT for production use — OTel globals cannot be re-registered after a real shutdown).



222
223
224
225
226
227
228
229
230
231
232
# File 'lib/foam/otel/init.rb', line 222

def reset_for_tests!
  @instances = {}
  @foam_providers = []
  @attached_providers = []
  @flushables = []
  @fallback_meter_provider = nil
  @metric_readers = []
  @pid = Process.pid
  @rails_enabled = false
  self.active_config = DEFAULT_CONFIG
end

.resolve_config(service_name:, endpoint: nil, redact_contact_info: false, redact_keys: nil, health_routes: nil) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/foam/otel/config.rb', line 40

def resolve_config(service_name:, endpoint: nil, redact_contact_info: false,
                   redact_keys: nil, health_routes: nil)
  keys = DEFAULT_REDACTED_KEYS.to_set
  keys.merge(CONTACT_INFO_KEYS) if redact_contact_info
  (redact_keys || []).each { |key| keys.add(key.downcase) }

  routes = DEFAULT_HEALTH_ROUTES.to_set
  (health_routes || []).each { |route| routes.add(route) }

  Config.new(
    service_name: service_name,
    endpoint: resolve_endpoint(endpoint),
    redacted_keys: keys.freeze,
    health_routes: routes.freeze
  ).freeze
end

.resolve_endpoint(endpoint) ⇒ Object



35
36
37
38
# File 'lib/foam/otel/config.rb', line 35

def resolve_endpoint(endpoint)
  resolved = endpoint || ENV.fetch("OTEL_EXPORTER_OTLP_ENDPOINT", nil) || FOAM_OTEL_ENDPOINT
  resolved.sub(%r{/+\z}, "")
end

.tracer(name = "foam") ⇒ Object

Signal passthrough: a tracer/meter/logger that exports to foam after init(). meter() serves the fallback provider when another SDK owns the global MeterProvider (SPEC section 9).



142
143
144
# File 'lib/foam/otel/init.rb', line 142

def tracer(name = "foam")
  OpenTelemetry.tracer_provider.tracer(name)
end