strongmind-platform-sdk 3.27.5 → 3.28.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10d6f1421ebf925d2fabec136b4afc8c4c98e93007858365f5232f2345e8082f
4
- data.tar.gz: e8b7b866bc67d307abde2fbaf2b8c1c6c2c3e55dddfc95bd67e5df197da9e1cb
3
+ metadata.gz: 97b493cd879ebbc714026b5d819ddfe8366453b0fb3d4e037ccf653dd6bf852e
4
+ data.tar.gz: bf03370e3b62cdc6732dcb9d1a0966782afa45722ac6f2bf15e121978c0d1fce
5
5
  SHA512:
6
- metadata.gz: cc9a23c8b4d71f2aa8effcd0e0c62e7e3eed2b2b09c03b0313e1eb4ec71f6fc6d1a19b1cddee0829265d265ce9b03115f7752bae073e7157bf05e306e577f927
7
- data.tar.gz: 766fea6294b2dde18c78d008741a8e80d0bf9610b9eb476422cdd0ccde51e367a2ba51da952358a752483071e35005f92988f184c529bb75d8f95072b61c7f9b
6
+ metadata.gz: 37074d6a5591e41ae57301530ffaed72b49bb21d07298ebd2c2434fcfe50cbd623f24b1fc6db4ee991b7b19c3eaa503014568a8e7b2548766265c8c90589dd0e
7
+ data.tar.gz: a539e7cad181064b9d617c020241a356dafb96102bfb2d1a63398476885c400b6736dfad46ab0b941e314654f6181960a46552bde1a3556ab0ea281b6a97e347
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.28.0] - 2026-05-05
4
+
5
+ - Add `PlatformSdk::Observability::Langfuse` module providing OpenTelemetry-based tracing exported to Langfuse Cloud via OTLP.
6
+ - `TracerProvider` is isolated (not installed globally) and coexists safely with `ddtrace`.
7
+ - Add `Traceable` Sidekiq concern (`include PlatformSdk::Observability::Langfuse::Traceable`) that wraps `perform` in a parent span.
8
+ - Add `record_generation` module method emitting `gen_ai.*` child spans inside an active trace.
9
+ - Add `NullSpanExporter` for use in tests (no network calls).
10
+ - Sidekiq shutdown flush hook is installed automatically when `configure` is called with valid keys.
11
+ - Add runtime dependencies: `opentelemetry-sdk`, `opentelemetry-exporter-otlp`.
12
+
3
13
  ## [0.1.0] - 2022-05-13
4
14
 
5
15
  - Initial release
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- strongmind-platform-sdk (3.27.0)
4
+ strongmind-platform-sdk (3.28.0)
5
5
  asset_sync
6
6
  aws-sdk-cloudwatch
7
7
  aws-sdk-secretsmanager (~> 1.66)
@@ -11,6 +11,8 @@ PATH
11
11
  jwt
12
12
  learnosity-sdk (~> 0.2.2)
13
13
  multi_json
14
+ opentelemetry-exporter-otlp (~> 0.27)
15
+ opentelemetry-sdk (~> 1.10)
14
16
  rails (>= 7.1)
15
17
  sentry-ruby
16
18
  sidekiq
@@ -97,8 +99,9 @@ GEM
97
99
  tzinfo (~> 2.0)
98
100
  addressable (2.8.7)
99
101
  public_suffix (>= 2.0.2, < 7.0)
100
- asset_sync (2.19.2)
102
+ asset_sync (2.19.3)
101
103
  activemodel (>= 4.1.0)
104
+ cgi
102
105
  fog-core
103
106
  mime-types (>= 2.99)
104
107
  unf
@@ -121,6 +124,7 @@ GEM
121
124
  base64 (0.2.0)
122
125
  bigdecimal (3.1.8)
123
126
  builder (3.3.0)
127
+ cgi (0.5.1)
124
128
  coderay (1.1.3)
125
129
  concurrent-ruby (1.3.3)
126
130
  connection_pool (2.4.1)
@@ -134,7 +138,7 @@ GEM
134
138
  erubi (1.13.0)
135
139
  ethon (0.16.0)
136
140
  ffi (>= 1.15.0)
137
- excon (1.3.0)
141
+ excon (1.4.2)
138
142
  logger
139
143
  factory_bot (6.4.6)
140
144
  activesupport (>= 5.0.0)
@@ -150,7 +154,7 @@ GEM
150
154
  ffi (1.17.0)
151
155
  ffi (1.17.0-x86_64-darwin)
152
156
  ffi (1.17.0-x86_64-linux-gnu)
153
- fog-aws (3.33.0)
157
+ fog-aws (3.33.1)
154
158
  base64 (>= 0.2, < 0.4)
155
159
  fog-core (~> 2.6)
156
160
  fog-json (~> 1.1)
@@ -166,10 +170,21 @@ GEM
166
170
  fog-xml (0.1.5)
167
171
  fog-core
168
172
  nokogiri (>= 1.5.11, < 2.0.0)
169
- formatador (1.2.2)
173
+ formatador (1.2.3)
170
174
  reline
171
175
  globalid (1.3.0)
172
176
  activesupport (>= 6.1)
177
+ google-protobuf (4.33.6)
178
+ bigdecimal
179
+ rake (>= 13)
180
+ google-protobuf (4.33.6-x86_64-darwin)
181
+ bigdecimal
182
+ rake (>= 13)
183
+ google-protobuf (4.33.6-x86_64-linux-gnu)
184
+ bigdecimal
185
+ rake (>= 13)
186
+ googleapis-common-protos-types (1.22.0)
187
+ google-protobuf (~> 4.26)
173
188
  hashdiff (1.1.0)
174
189
  i18n (1.14.5)
175
190
  concurrent-ruby (~> 1.0)
@@ -198,7 +213,7 @@ GEM
198
213
  mime-types (3.7.0)
199
214
  logger
200
215
  mime-types-data (~> 3.2025, >= 3.2025.0507)
201
- mime-types-data (3.2025.0924)
216
+ mime-types-data (3.2026.0317)
202
217
  mini_mime (1.1.5)
203
218
  mini_portile2 (2.8.7)
204
219
  minitest (5.24.1)
@@ -206,7 +221,7 @@ GEM
206
221
  mutex_m (0.2.0)
207
222
  net-http (0.4.1)
208
223
  uri
209
- net-imap (0.5.12)
224
+ net-imap (0.6.3)
210
225
  date
211
226
  net-protocol
212
227
  net-pop (0.1.2)
@@ -223,6 +238,26 @@ GEM
223
238
  racc (~> 1.4)
224
239
  nokogiri (1.16.7-x86_64-linux)
225
240
  racc (~> 1.4)
241
+ opentelemetry-api (1.8.0)
242
+ logger
243
+ opentelemetry-common (0.23.0)
244
+ opentelemetry-api (~> 1.0)
245
+ opentelemetry-exporter-otlp (0.32.0)
246
+ google-protobuf (>= 3.18)
247
+ googleapis-common-protos-types (~> 1.3)
248
+ opentelemetry-api (~> 1.1)
249
+ opentelemetry-common (~> 0.20)
250
+ opentelemetry-sdk (~> 1.10)
251
+ opentelemetry-semantic_conventions
252
+ opentelemetry-registry (0.4.0)
253
+ opentelemetry-api (~> 1.1)
254
+ opentelemetry-sdk (1.10.0)
255
+ opentelemetry-api (~> 1.1)
256
+ opentelemetry-common (~> 0.20)
257
+ opentelemetry-registry (~> 0.2)
258
+ opentelemetry-semantic_conventions
259
+ opentelemetry-semantic_conventions (1.36.0)
260
+ opentelemetry-api (~> 1.0)
226
261
  parallel (1.25.1)
227
262
  parser (3.3.4.0)
228
263
  ast (~> 2.4.1)
@@ -342,7 +377,7 @@ GEM
342
377
  ffi (~> 1.1)
343
378
  thor (1.3.1)
344
379
  timecop (0.9.10)
345
- timeout (0.4.4)
380
+ timeout (0.6.1)
346
381
  typhoeus (1.4.1)
347
382
  ethon (>= 0.9.0)
348
383
  tzinfo (2.0.6)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlatformSdk
4
+ module Observability
5
+ module Langfuse
6
+ # Shared coercion helpers used by Configuration, Recorder, and the
7
+ # Traceable PerformWrapper. Extracted to a single home so the
8
+ # OpenTelemetry attribute-validator workaround (`String.new` to
9
+ # produce a plain String rather than a subclass) lives in one place.
10
+ module Coercions
11
+ module_function
12
+
13
+ # Returns `nil` for nil; otherwise a plain `String` (not a
14
+ # subclass). OTel's attribute validator uses `instance_of?(String)`
15
+ # — subclasses like `ActiveSupport::SafeBuffer` fail validation and
16
+ # crash `Resource.create`.
17
+ def coerce_string(value)
18
+ return nil if value.nil?
19
+
20
+ String.new(value.to_s)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry/sdk'
4
+ require 'opentelemetry/exporter/otlp'
5
+ require 'base64'
6
+ require 'uri'
7
+
8
+ module PlatformSdk
9
+ module Observability
10
+ module Langfuse
11
+ class Configuration
12
+ DEFAULT_BASE_URL = 'https://us.cloud.langfuse.com' # US region; set LANGFUSE_BASE_URL for eu.cloud.langfuse.com, jp.cloud.langfuse.com, etc.
13
+ DEFAULT_PROMPT_LABEL = 'latest'
14
+ DEFAULT_BATCH_INTERVAL_MS = 5_000
15
+ OTLP_TRACES_PATH = '/api/public/otel/v1/traces'
16
+
17
+ attr_reader :app_name, :environment, :prompt_label, :exporter
18
+
19
+ def initialize(app_name:, environment: nil, prompt_label: nil, exporter: nil)
20
+ @app_name = coerce_string(app_name)
21
+ @environment = coerce_string(environment || ENV.fetch('RAILS_ENV', 'development'))
22
+ @prompt_label = coerce_string(prompt_label || ENV.fetch('LANGFUSE_PROMPT_LABEL', DEFAULT_PROMPT_LABEL))
23
+ @exporter = exporter
24
+ @enabled = compute_enabled
25
+ end
26
+
27
+ def enabled?
28
+ @enabled
29
+ end
30
+
31
+ def otlp_endpoint
32
+ "#{base_url}#{OTLP_TRACES_PATH}"
33
+ end
34
+
35
+ def default_resource_attributes
36
+ {
37
+ 'service.name' => app_name,
38
+ 'deployment.environment' => environment
39
+ }
40
+ end
41
+
42
+ def tracer
43
+ @tracer ||= tracer_provider.tracer('platform_sdk.observability.langfuse', PlatformSdk::VERSION)
44
+ end
45
+
46
+ def tracer_provider
47
+ @tracer_provider ||= build_tracer_provider
48
+ end
49
+
50
+ def force_flush_and_shutdown
51
+ return if @shut_down
52
+ return unless @tracer_provider
53
+
54
+ @tracer_provider.force_flush(timeout: 5)
55
+ @tracer_provider.shutdown(timeout: 5)
56
+ @shut_down = true
57
+ end
58
+
59
+ private
60
+
61
+ # Snapshot the enabled state at construction time so credentials
62
+ # mutated in ENV after `configure` don't silently flip behavior
63
+ # mid-process. Callers who need to roll keys must re-`configure`.
64
+ def compute_enabled
65
+ return false if ENV['LANGFUSE_ENABLED'] == 'false'
66
+
67
+ public_key.to_s != '' && secret_key.to_s != ''
68
+ end
69
+
70
+ def coerce_string(value)
71
+ Coercions.coerce_string(value)
72
+ end
73
+
74
+ def public_key
75
+ ENV['LANGFUSE_PUBLIC_KEY']
76
+ end
77
+
78
+ def secret_key
79
+ ENV['LANGFUSE_SECRET_KEY']
80
+ end
81
+
82
+ def base_url
83
+ @base_url ||= validated_base_url
84
+ end
85
+
86
+ def validated_base_url
87
+ url = ENV.fetch('LANGFUSE_BASE_URL', DEFAULT_BASE_URL)
88
+ uri = URI.parse(url)
89
+ unless %w[http https].include?(uri.scheme) && !uri.host.nil?
90
+ raise ConfigurationError, "LANGFUSE_BASE_URL must be an http(s) URL with a host (got: #{url.inspect})"
91
+ end
92
+
93
+ url
94
+ rescue URI::InvalidURIError => e
95
+ raise ConfigurationError, "LANGFUSE_BASE_URL is not a valid URI: #{e.message}"
96
+ end
97
+
98
+ def batch_interval_ms
99
+ ENV.fetch('LANGFUSE_BATCH_INTERVAL_MS', DEFAULT_BATCH_INTERVAL_MS).to_i
100
+ end
101
+
102
+ # Builds an isolated TracerProvider that is intentionally NOT installed
103
+ # as the OpenTelemetry global (no `OpenTelemetry.tracer_provider =`,
104
+ # no `OpenTelemetry::SDK.configure`). Datadog APM (ddtrace) hooks the
105
+ # global tracer provider via `require 'datadog/opentelemetry'`; keeping
106
+ # this provider isolated lets Langfuse and ddtrace coexist without
107
+ # fighting over the same global state. See:
108
+ # https://github.com/DataDog/dd-trace-rb/blob/master/docs/OpenTelemetry.md
109
+ def build_tracer_provider
110
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
111
+ resource: OpenTelemetry::SDK::Resources::Resource.create(default_resource_attributes),
112
+ span_limits: span_limits
113
+ )
114
+ provider.add_span_processor(span_processor)
115
+ provider
116
+ end
117
+
118
+ def span_limits
119
+ OpenTelemetry::SDK::Trace::SpanLimits.new(
120
+ attribute_length_limit: Recorder::MAX_ATTRIBUTE_BYTES
121
+ )
122
+ end
123
+
124
+ def span_processor
125
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
126
+ exporter || build_otlp_exporter,
127
+ schedule_delay: batch_interval_ms,
128
+ max_queue_size: 2048,
129
+ max_export_batch_size: 512
130
+ )
131
+ end
132
+
133
+ def build_otlp_exporter
134
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
135
+ endpoint: otlp_endpoint,
136
+ headers: {
137
+ 'Authorization' => basic_auth_header,
138
+ 'x-langfuse-ingestion-version' => '4'
139
+ }
140
+ )
141
+ end
142
+
143
+ def basic_auth_header
144
+ token = Base64.strict_encode64("#{public_key}:#{secret_key}")
145
+ "Basic #{token}"
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'opentelemetry/sdk'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ # Span exporter for tests and dev. Wraps OTel's `InMemorySpanExporter`
9
+ # with two minor adjustments:
10
+ #
11
+ # 1. `shutdown` is a no-op (the base class clears `@finished_spans`
12
+ # on shutdown). Our tests pervasively call
13
+ # `configuration.force_flush_and_shutdown` and then assert on
14
+ # exported spans — preserving the buffer across shutdown is
15
+ # what makes that ergonomics work.
16
+ # 2. `exported_spans` / `clear` aliases for `finished_spans` /
17
+ # `reset` so the names line up with how we talk about this
18
+ # thing elsewhere in the SDK (and don't change call sites in
19
+ # consumer apps).
20
+ class NullSpanExporter < OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter
21
+ alias exported_spans finished_spans
22
+ alias clear reset
23
+
24
+ def shutdown(timeout: nil)
25
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ module Recorder
9
+ MAX_ATTRIBUTE_BYTES = 262_144 # 256KB
10
+ TRUNCATION_SUFFIX = "…[truncated]"
11
+
12
+ # rubocop:disable Metrics/ParameterLists
13
+ def self.record_generation(name:, model:, input:, output:, prompt_name: nil, prompt_version: nil, usage: nil, provider: nil)
14
+ return unless Langfuse.enabled?
15
+ return unless current_span_active?
16
+
17
+ tracer = Langfuse.tracer
18
+ tracer.in_span(name, attributes: build_attributes(model, input, output, prompt_name, prompt_version, usage, provider)) do
19
+ # span body intentionally empty — attributes carry the data
20
+ end
21
+ end
22
+ # rubocop:enable Metrics/ParameterLists
23
+
24
+ def self.current_span_active?
25
+ OpenTelemetry::Trace.current_span.context.valid?
26
+ end
27
+
28
+ # rubocop:disable Metrics/ParameterLists
29
+ def self.build_attributes(model, input, output, prompt_name, prompt_version, usage, provider = nil)
30
+ attrs = {
31
+ 'langfuse.observation.type' => 'generation',
32
+ # `gen_ai.operation.name` is a required attribute in the OTel
33
+ # GenAI semantic conventions. We only emit chat-style
34
+ # completions today, so this is a constant.
35
+ 'gen_ai.operation.name' => 'chat',
36
+ 'gen_ai.request.model' => coerce_string(model),
37
+ 'gen_ai.prompt' => stringify(input),
38
+ 'gen_ai.completion' => stringify(output)
39
+ }
40
+ attrs['gen_ai.provider.name'] = coerce_string(provider) if provider
41
+ if prompt_name && prompt_version
42
+ attrs['langfuse.observation.prompt.name'] = coerce_string(prompt_name)
43
+ attrs['langfuse.observation.prompt.version'] = coerce_simple(prompt_version)
44
+ end
45
+ if usage.is_a?(Hash)
46
+ usage = usage.transform_keys(&:to_sym)
47
+ input_tokens = coerce_integer(usage[:input_tokens])
48
+ output_tokens = coerce_integer(usage[:output_tokens])
49
+ attrs['gen_ai.usage.input_tokens'] = input_tokens unless input_tokens.nil?
50
+ attrs['gen_ai.usage.output_tokens'] = output_tokens unless output_tokens.nil?
51
+ end
52
+ attrs.compact
53
+ end
54
+ # rubocop:enable Metrics/ParameterLists
55
+
56
+ def self.coerce_string(value)
57
+ Coercions.coerce_string(value)
58
+ end
59
+
60
+ # Pass numerics/booleans through; coerce everything else to plain String.
61
+ def self.coerce_simple(value)
62
+ return nil if value.nil?
63
+ return value if value.instance_of?(Integer) || value.instance_of?(Float)
64
+ return value if value.instance_of?(TrueClass) || value.instance_of?(FalseClass)
65
+
66
+ String.new(value.to_s)
67
+ end
68
+
69
+ def self.coerce_integer(value)
70
+ return nil if value.nil?
71
+
72
+ Integer(value)
73
+ rescue ArgumentError, TypeError
74
+ nil
75
+ end
76
+
77
+ def self.stringify(value)
78
+ str = value.is_a?(String) ? value : value.to_json
79
+ truncated = truncate(str)
80
+ # Force a plain String — value.to_json could be a String subclass
81
+ # in some odd cases, and value.is_a?(String) lets subclasses through.
82
+ String.new(truncated)
83
+ rescue StandardError
84
+ String.new(truncate(value.to_s))
85
+ end
86
+
87
+ def self.truncate(str)
88
+ return str if str.bytesize <= MAX_ATTRIBUTE_BYTES
89
+
90
+ # Slice on byte boundary then strip any partial multibyte sequence by encoding-coercing.
91
+ truncated = str.byteslice(0, MAX_ATTRIBUTE_BYTES).scrub('')
92
+ "#{truncated}#{TRUNCATION_SUFFIX}"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ module SidekiqLifecycle
9
+ @installed = false
10
+ @mutex = Mutex.new
11
+
12
+ class << self
13
+ def install!
14
+ @mutex.synchronize do
15
+ return if @installed
16
+
17
+ @installed = true
18
+ ::Sidekiq.configure_server do |config|
19
+ config.on(:shutdown) do
20
+ PlatformSdk::Observability::Langfuse.configuration&.force_flush_and_shutdown
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ def reset!
27
+ @mutex.synchronize { @installed = false }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test-environment wiring for PlatformSdk::Observability::Langfuse.
4
+ #
5
+ # Consuming apps add this one line to their `spec/rails_helper.rb` (or
6
+ # `spec_helper.rb`):
7
+ #
8
+ # require 'platform_sdk/observability/langfuse/spec_support'
9
+ # PlatformSdk::Observability::Langfuse::SpecSupport.install!(app_name: 'my-app-test')
10
+ #
11
+ # What it does:
12
+ # * Configures the Langfuse module with a NullSpanExporter so no live network
13
+ # calls happen during tests.
14
+ # * Clears the exporter between examples so spans don't bleed across tests.
15
+ # * Mixes a `langfuse_exported_spans` helper into every example so specs can
16
+ # flush + read the exported spans for assertions.
17
+
18
+ module PlatformSdk
19
+ module Observability
20
+ module Langfuse
21
+ module SpecSupport
22
+ DEFAULT_TEST_PUBLIC_KEY = 'pk-test'
23
+ DEFAULT_TEST_SECRET_KEY = 'sk-test'
24
+ DEFAULT_TEST_ENVIRONMENT = 'test'
25
+
26
+ # Register the RSpec lifecycle hooks. Call once from `rails_helper.rb`
27
+ # (or `spec_helper.rb`). The actual NullSpanExporter wiring happens in
28
+ # `before(:suite)`; this method just sets up the hooks.
29
+ def self.install!(app_name:, environment: DEFAULT_TEST_ENVIRONMENT)
30
+ require 'rspec/core'
31
+
32
+ RSpec.configure do |config|
33
+ config.before(:suite) do
34
+ PlatformSdk::Observability::Langfuse::SpecSupport.apply!(
35
+ app_name: app_name,
36
+ environment: environment
37
+ )
38
+ end
39
+ config.before(:each) do
40
+ PlatformSdk::Observability::Langfuse::SpecSupport.exporter&.clear
41
+ end
42
+ config.include TestHelpers
43
+ end
44
+ end
45
+
46
+ # Configure the Langfuse module with a NullSpanExporter. Separate from
47
+ # `install!` so it can be invoked directly in specs that need to test
48
+ # SpecSupport itself, or by apps that drive their own lifecycle.
49
+ def self.apply!(app_name:, environment: DEFAULT_TEST_ENVIRONMENT)
50
+ ENV['LANGFUSE_PUBLIC_KEY'] ||= DEFAULT_TEST_PUBLIC_KEY
51
+ ENV['LANGFUSE_SECRET_KEY'] ||= DEFAULT_TEST_SECRET_KEY
52
+
53
+ PlatformSdk::Observability::Langfuse.reset!
54
+ PlatformSdk::Observability::Langfuse.configure(
55
+ app_name: app_name,
56
+ environment: environment,
57
+ exporter: NullSpanExporter.new
58
+ )
59
+ end
60
+
61
+ # The currently-configured NullSpanExporter, or nil if `install!` was
62
+ # not called or the configuration was reset.
63
+ def self.exporter
64
+ config = PlatformSdk::Observability::Langfuse.configuration
65
+ return nil unless config
66
+
67
+ config.exporter
68
+ end
69
+
70
+ module TestHelpers
71
+ # Force-flush the BatchSpanProcessor and return every span the
72
+ # NullSpanExporter has captured during the current example. Use this
73
+ # to assert that a code path produced the expected spans.
74
+ #
75
+ # Example:
76
+ # spans = langfuse_exported_spans
77
+ # parent = spans.find { |s| s.name == 'MyJob' }
78
+ # expect(parent.attributes['langfuse.trace.tags']).to include('test')
79
+ def langfuse_exported_spans
80
+ config = PlatformSdk::Observability::Langfuse.configuration
81
+ config.tracer_provider.force_flush(timeout: 5)
82
+ PlatformSdk::Observability::Langfuse::SpecSupport.exporter.exported_spans
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ module Traceable
9
+ extend ActiveSupport::Concern
10
+
11
+ # PerformWrapper is prepended into the including class so that its
12
+ # perform override runs before the class's own perform definition,
13
+ # regardless of the order in which include and def appear.
14
+ module PerformWrapper
15
+ REENTRY_KEY = :platform_sdk_langfuse_traceable_active
16
+
17
+ def perform(*args)
18
+ return super(*args) unless PlatformSdk::Observability::Langfuse.enabled?
19
+
20
+ # Re-entry guard: subclass `inherited` re-prepends PerformWrapper at
21
+ # every level of the ancestry, so a subclass `perform` that calls
22
+ # `super` would otherwise hit the wrapper at each intermediate class
23
+ # and open one span per level. Only the outermost wrapper opens a
24
+ # span; inner re-entries just delegate.
25
+ return super(*args) if Thread.current[REENTRY_KEY]
26
+
27
+ Thread.current[REENTRY_KEY] = true
28
+ begin
29
+ tracer = PlatformSdk::Observability::Langfuse.tracer
30
+ tracer.in_span(self.class.langfuse_span_name, attributes: safe_langfuse_span_attributes(args)) do
31
+ super(*args)
32
+ end
33
+ ensure
34
+ Thread.current[REENTRY_KEY] = false
35
+ PlatformSdk::Observability::Langfuse.clear_tracked_thread_locals!
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Build attributes, but never raise out — a bad value should degrade tracing,
42
+ # not crash the wrapped job.
43
+ def safe_langfuse_span_attributes(args)
44
+ langfuse_span_attributes(args)
45
+ rescue StandardError => e
46
+ OpenTelemetry.handle_error(message: "Langfuse attribute build failed: #{e.message}") if defined?(::OpenTelemetry)
47
+ {}
48
+ end
49
+
50
+ def langfuse_span_attributes(args)
51
+ attrs = {
52
+ 'sidekiq.jid' => coerce_string(jid_if_available),
53
+ 'langfuse.trace.input' => coerce_string(args.to_json)
54
+ }.compact
55
+ tags = combined_langfuse_tags
56
+ attrs['langfuse.trace.tags'] = tags if tags.any?
57
+ attrs
58
+ end
59
+
60
+ def jid_if_available
61
+ respond_to?(:jid) ? jid : nil
62
+ end
63
+
64
+ def combined_langfuse_tags
65
+ config = PlatformSdk::Observability::Langfuse.configuration
66
+ base_tags = config ? [config.environment, config.prompt_label].compact : []
67
+ (base_tags + Array(self.class.langfuse_extra_tags)).uniq.map { |t| coerce_string(t) }.compact
68
+ end
69
+
70
+ def coerce_string(value)
71
+ Coercions.coerce_string(value)
72
+ end
73
+ end
74
+
75
+ included do
76
+ prepend PerformWrapper
77
+
78
+ # Subclasses that define their own perform would shadow PerformWrapper
79
+ # (which sits in the parent class's chain). Re-prepend on every subclass
80
+ # so the wrapper sits above each subclass's own perform definition.
81
+ def self.inherited(subclass)
82
+ super
83
+ subclass.prepend PerformWrapper
84
+ end
85
+ end
86
+
87
+ class_methods do
88
+ # Optional override hook so consumers can change the span name or pre-set tags.
89
+ def traced_with_langfuse(name: nil, tags: [])
90
+ @langfuse_span_name = name
91
+ @langfuse_extra_tags = Array(tags)
92
+ end
93
+
94
+ def langfuse_span_name
95
+ @langfuse_span_name || name
96
+ end
97
+
98
+ def langfuse_extra_tags
99
+ @langfuse_extra_tags || []
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'platform_sdk/observability/langfuse/coercions'
4
+ require 'platform_sdk/observability/langfuse/configuration'
5
+ require 'platform_sdk/observability/langfuse/null_span_exporter'
6
+ require 'platform_sdk/observability/langfuse/recorder'
7
+ require 'platform_sdk/observability/langfuse/traceable'
8
+ require 'platform_sdk/observability/langfuse/sidekiq_lifecycle'
9
+
10
+ module PlatformSdk
11
+ module Observability
12
+ module Langfuse
13
+ class Error < StandardError; end
14
+ class ConfigurationError < Error; end
15
+
16
+ OWNED_THREAD_LOCALS_KEY = :platform_sdk_langfuse_owned_thread_locals
17
+
18
+ class << self
19
+ attr_reader :configuration
20
+
21
+ def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil)
22
+ @configuration&.force_flush_and_shutdown
23
+ @configuration = Configuration.new(
24
+ app_name: app_name,
25
+ environment: environment,
26
+ prompt_label: prompt_label,
27
+ exporter: exporter
28
+ )
29
+ SidekiqLifecycle.install! if @configuration.enabled?
30
+ @configuration
31
+ end
32
+
33
+ def enabled?
34
+ !@configuration.nil? && @configuration.enabled?
35
+ end
36
+
37
+ def tracer
38
+ return nil unless enabled?
39
+
40
+ @configuration.tracer
41
+ end
42
+
43
+ def record_generation(**kwargs)
44
+ Recorder.record_generation(**kwargs)
45
+ end
46
+
47
+ # Set the trace-level output on the active parent span. Application
48
+ # code calls this from inside a Traceable job (or any block running
49
+ # under an active span) to record a meaningful summary of the job's
50
+ # result.
51
+ #
52
+ # Why this is opt-in rather than auto-captured from `perform`'s return
53
+ # value: Sidekiq's `perform` return is incidental — it's often nil, an
54
+ # AR transaction callback array, or otherwise unrelated to anything a
55
+ # human reading a trace would find useful. Only the application knows
56
+ # what's meaningful (e.g. a generated record's ID).
57
+ #
58
+ # Example:
59
+ # class MyJob
60
+ # include Sidekiq::Job
61
+ # include PlatformSdk::Observability::Langfuse::Traceable
62
+ #
63
+ # def perform(args)
64
+ # component = generate_component(args)
65
+ # PlatformSdk::Observability::Langfuse.set_trace_output(
66
+ # { component_id: component.id, status: component.status }
67
+ # )
68
+ # end
69
+ # end
70
+ #
71
+ # No-ops when the SDK is disabled or no span is currently active.
72
+ # Never raises — observability failures must not break callers.
73
+ def set_trace_output(value)
74
+ return unless enabled?
75
+
76
+ span = OpenTelemetry::Trace.current_span
77
+ return unless span.context.valid?
78
+
79
+ span.set_attribute('langfuse.trace.output', Recorder.stringify(value))
80
+ rescue StandardError, SystemStackError => e
81
+ OpenTelemetry.handle_error(message: "Langfuse set_trace_output failed: #{e.class}: #{e.message[0, 200]}")
82
+ nil
83
+ end
84
+
85
+ def reset!
86
+ @configuration&.force_flush_and_shutdown
87
+ @configuration = nil
88
+ SidekiqLifecycle.reset!
89
+ end
90
+
91
+ # Register thread-local keys that should be cleared at the next
92
+ # Traceable job boundary. Consumers call this when they store
93
+ # per-trace state on `Thread.current` (e.g. the ID of the last
94
+ # recorded message) so the value can't leak into the next job
95
+ # that lands on this Sidekiq thread.
96
+ #
97
+ # Example:
98
+ # PlatformSdk::Observability::Langfuse.track_thread_local(:my_app_last_message_id)
99
+ # Thread.current[:my_app_last_message_id] = msg.id
100
+ #
101
+ # Compared to a name-prefix convention, this requires explicit
102
+ # opt-in (so consumers can't accidentally have unrelated state
103
+ # zeroed) and is O(owned keys) instead of O(all thread locals).
104
+ def track_thread_local(*keys)
105
+ list = (Thread.current[OWNED_THREAD_LOCALS_KEY] ||= [])
106
+ keys.each { |k| list << k unless list.include?(k) }
107
+ nil
108
+ end
109
+
110
+ # Clear every thread-local registered via `track_thread_local` on
111
+ # this thread, then drop the registry itself. Called from the
112
+ # Traceable wrapper's `ensure` block.
113
+ def clear_tracked_thread_locals!
114
+ owned = Thread.current[OWNED_THREAD_LOCALS_KEY]
115
+ return unless owned
116
+
117
+ owned.each { |k| Thread.current[k] = nil }
118
+ Thread.current[OWNED_THREAD_LOCALS_KEY] = nil
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'platform_sdk/observability/langfuse'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ end
8
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
- MINOR = 27
6
- PATCH = 5
5
+ MINOR = 28
6
+ PATCH = 0
7
7
 
8
8
  VERSION = "#{PlatformSdk::MAJOR}.#{PlatformSdk::MINOR}.#{PlatformSdk::PATCH}"
9
9
  end
data/lib/platform_sdk.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'platform_sdk/version'
4
+ require 'platform_sdk/observability'
4
5
  require 'platform_sdk/one_roster'
5
6
  require 'platform_sdk/identity'
6
7
  require 'platform_sdk/power_school'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strongmind-platform-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.27.5
4
+ version: 3.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Platform Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-15 00:00:00.000000000 Z
11
+ date: 2026-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -184,6 +184,34 @@ dependencies:
184
184
  - - ">="
185
185
  - !ruby/object:Gem::Version
186
186
  version: '0'
187
+ - !ruby/object:Gem::Dependency
188
+ name: opentelemetry-sdk
189
+ requirement: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '1.10'
194
+ type: :runtime
195
+ prerelease: false
196
+ version_requirements: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '1.10'
201
+ - !ruby/object:Gem::Dependency
202
+ name: opentelemetry-exporter-otlp
203
+ requirement: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '0.27'
208
+ type: :runtime
209
+ prerelease: false
210
+ version_requirements: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: '0.27'
187
215
  - !ruby/object:Gem::Dependency
188
216
  name: asset_sync
189
217
  requirement: !ruby/object:Gem::Requirement
@@ -286,6 +314,15 @@ files:
286
314
  - lib/platform_sdk/llm_gateway/client.rb
287
315
  - lib/platform_sdk/logging.rb
288
316
  - lib/platform_sdk/logging/pii_formatter.rb
317
+ - lib/platform_sdk/observability.rb
318
+ - lib/platform_sdk/observability/langfuse.rb
319
+ - lib/platform_sdk/observability/langfuse/coercions.rb
320
+ - lib/platform_sdk/observability/langfuse/configuration.rb
321
+ - lib/platform_sdk/observability/langfuse/null_span_exporter.rb
322
+ - lib/platform_sdk/observability/langfuse/recorder.rb
323
+ - lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb
324
+ - lib/platform_sdk/observability/langfuse/spec_support.rb
325
+ - lib/platform_sdk/observability/langfuse/traceable.rb
289
326
  - lib/platform_sdk/one_roster.rb
290
327
  - lib/platform_sdk/one_roster/client.rb
291
328
  - lib/platform_sdk/ops_genie.rb