strongmind-platform-sdk 3.28.0 → 3.29.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: 97b493cd879ebbc714026b5d819ddfe8366453b0fb3d4e037ccf653dd6bf852e
4
- data.tar.gz: bf03370e3b62cdc6732dcb9d1a0966782afa45722ac6f2bf15e121978c0d1fce
3
+ metadata.gz: 799fe97b77fb105775c8b846e6916380be6845209a5157633d832186ae81bd25
4
+ data.tar.gz: ee0bbab328e6e27bcf72a5e634535f9ed0ab91bb57976b94a9c1fd24fac268f1
5
5
  SHA512:
6
- metadata.gz: 37074d6a5591e41ae57301530ffaed72b49bb21d07298ebd2c2434fcfe50cbd623f24b1fc6db4ee991b7b19c3eaa503014568a8e7b2548766265c8c90589dd0e
7
- data.tar.gz: a539e7cad181064b9d617c020241a356dafb96102bfb2d1a63398476885c400b6736dfad46ab0b941e314654f6181960a46552bde1a3556ab0ea281b6a97e347
6
+ metadata.gz: 7080031758db9d9bd0894dcc2b8054df0377f1bc33a6da0ba987f7be9888c343cb0e2feb83d9d811c95114e1891ff4a62c1cde7d445333b35a5bcbf6a2b4007a
7
+ data.tar.gz: c8e8bb3e06b1b6808cbc02c50664921dd6850520050cf5e0191a4bc01b3e2e4357bb8d0fa4c4edd14a95db1134d351a5ca3fbd1105243eb0cfd317ac85955f80
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.29.0] - 2026-05-12
4
+
5
+ - Add `PlatformSdk::Observability::Langfuse::NotificationSubscriber` — subscribes to the `llm_call.platform_sdk` `ActiveSupport::Notifications` event and forwards to `Recorder.record_generation`. Apps can instrument LLM calls without coupling to a host concern; adding a second observability backend later is one more `subscribe` call.
6
+ - Auto-installed by `Langfuse.configure` (opt out with `autosubscribe: false`).
7
+ - Payload contract: `name`, `model`, `input`, `output`, `usage: { input_tokens:, output_tokens: }`, `prompt: { name:, version: }`, `provider`, `error:` (optional Exception — when set, recorded as a failure observation).
8
+ - Add `PlatformSdk::Observability::Langfuse::TraceSummarizable` mixin — template method for jobs to record a meaningful summary on the trace's Output column via `record_langfuse_trace_output` + `langfuse_trace_output_attributes`.
9
+
3
10
  ## [3.28.0] - 2026-05-05
4
11
 
5
12
  - Add `PlatformSdk::Observability::Langfuse` module providing OpenTelemetry-based tracing exported to Langfuse Cloud via OTLP.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- strongmind-platform-sdk (3.28.0)
4
+ strongmind-platform-sdk (3.29.0)
5
5
  asset_sync
6
6
  aws-sdk-cloudwatch
7
7
  aws-sdk-secretsmanager (~> 1.66)
@@ -154,7 +154,7 @@ GEM
154
154
  ffi (1.17.0)
155
155
  ffi (1.17.0-x86_64-darwin)
156
156
  ffi (1.17.0-x86_64-linux-gnu)
157
- fog-aws (3.33.1)
157
+ fog-aws (3.33.2)
158
158
  base64 (>= 0.2, < 0.4)
159
159
  fog-core (~> 2.6)
160
160
  fog-json (~> 1.1)
@@ -164,7 +164,7 @@ GEM
164
164
  excon (~> 1.0)
165
165
  formatador (>= 0.2, < 2.0)
166
166
  mime-types
167
- fog-json (1.2.0)
167
+ fog-json (1.3.0)
168
168
  fog-core
169
169
  multi_json (~> 1.10)
170
170
  fog-xml (0.1.5)
@@ -213,7 +213,7 @@ GEM
213
213
  mime-types (3.7.0)
214
214
  logger
215
215
  mime-types-data (~> 3.2025, >= 3.2025.0507)
216
- mime-types-data (3.2026.0317)
216
+ mime-types-data (3.2026.0414)
217
217
  mini_mime (1.1.5)
218
218
  mini_portile2 (2.8.7)
219
219
  minitest (5.24.1)
@@ -221,7 +221,7 @@ GEM
221
221
  mutex_m (0.2.0)
222
222
  net-http (0.4.1)
223
223
  uri
224
- net-imap (0.6.3)
224
+ net-imap (0.6.4)
225
225
  date
226
226
  net-protocol
227
227
  net-pop (0.1.2)
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ # Subscribes to `llm_call.platform_sdk` ActiveSupport::Notifications events
9
+ # and forwards them to `Recorder.record_generation`.
10
+ #
11
+ # Apps instrument their LLM calls with the convention:
12
+ #
13
+ # ActiveSupport::Notifications.instrument(
14
+ # 'llm_call.platform_sdk',
15
+ # name: 'chat_response',
16
+ # model: 'claude-3-5-sonnet',
17
+ # input: prompt_messages,
18
+ # output: response_text,
19
+ # usage: { input_tokens: 120, output_tokens: 84 }
20
+ # ) { call_the_llm }
21
+ #
22
+ # Inside the block, the payload Hash can be mutated to enrich it with
23
+ # values only known after the call completes. (`Instrumenter#instrument`
24
+ # passes the same Hash to the block and reads it again after `yield`;
25
+ # this isn't documented in the Rails Guide but is relied on across
26
+ # the ecosystem.)
27
+ #
28
+ # Two error-passing paths:
29
+ #
30
+ # 1. **Direct `instrument`-with-a-block callers** that let the block
31
+ # raise: Rails populates `:exception_object` automatically and this
32
+ # subscriber records a failure observation from it.
33
+ #
34
+ # 2. **Caller-rescues-and-rethrows** patterns (e.g. `RubyLLMAdapter.fire`,
35
+ # course-builder's `LlmErrorHandling#with_llm_error_handling`): set
36
+ # `payload[:error] = exception` before firing. Rails only auto-sets
37
+ # `:exception_object` for the block form, so adapter-style callers
38
+ # that `instrument` without a block need to populate `:error`.
39
+ #
40
+ # `format_output` prefers `:error` when both are set so the
41
+ # caller-provided value wins.
42
+ #
43
+ # Idempotent: `install!` is safe to call repeatedly — only the first
44
+ # call registers a subscriber.
45
+ module NotificationSubscriber
46
+ EVENT_NAME = LLM_CALL_EVENT
47
+ DEFAULT_NAME = 'llm_call'
48
+
49
+ @installed = false
50
+ @subscriber = nil
51
+ @mutex = Mutex.new
52
+
53
+ class << self
54
+ def install!
55
+ @mutex.synchronize do
56
+ return if @installed
57
+
58
+ @subscriber = ActiveSupport::Notifications.subscribe(EVENT_NAME) do |_name, _start, _finish, _id, payload|
59
+ handle_event(payload)
60
+ end
61
+ @installed = true
62
+ end
63
+ end
64
+
65
+ def reset!
66
+ @mutex.synchronize do
67
+ ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
68
+ @subscriber = nil
69
+ @installed = false
70
+ end
71
+ end
72
+
73
+ def installed?
74
+ @installed
75
+ end
76
+
77
+ private
78
+
79
+ def handle_event(payload)
80
+ payload ||= {}
81
+ Langfuse.record_generation(**build_kwargs(payload))
82
+ rescue StandardError => e
83
+ OpenTelemetry.handle_error(
84
+ message: "Langfuse notification subscriber failed: #{e.class}: #{e.message[0, 200]}"
85
+ )
86
+ end
87
+
88
+ def build_kwargs(payload)
89
+ warn_if_misshaped_prompt(payload[:prompt])
90
+ prompt = payload[:prompt].is_a?(Hash) ? payload[:prompt] : {}
91
+ {
92
+ name: payload[:name] || DEFAULT_NAME, model: payload[:model],
93
+ input: payload[:input], output: format_output(payload),
94
+ usage: payload[:usage],
95
+ prompt_name: prompt[:name], prompt_version: prompt[:version],
96
+ provider: payload[:provider]
97
+ }
98
+ end
99
+
100
+ # Surfaces a non-Hash `:prompt` value through OpenTelemetry so
101
+ # callers see the misconfiguration in development instead of
102
+ # silently losing prompt tracing. A plain string like
103
+ # `prompt: 'my_prompt_name'` (easy to confuse with `name:`) would
104
+ # otherwise fall back to `{}` with no feedback.
105
+ def warn_if_misshaped_prompt(raw_prompt)
106
+ return if raw_prompt.nil? || raw_prompt.is_a?(Hash)
107
+
108
+ OpenTelemetry.handle_error(
109
+ message: 'Langfuse NotificationSubscriber: :prompt must be a Hash with :name and :version ' \
110
+ "(got #{raw_prompt.class}); prompt info will not be recorded"
111
+ )
112
+ end
113
+
114
+ # `:error` (caller-set) takes precedence over `:exception_object`
115
+ # (Rails-set when an instrument block raises) so a caller that
116
+ # rescues and re-fires has the final say. Cap the formatted
117
+ # message so a multi-MB upstream error body can't be forwarded
118
+ # verbatim to the telemetry sink.
119
+ def format_output(payload)
120
+ error = payload[:error] || payload[:exception_object]
121
+ error ? "[#{error.class}] #{error.message.to_s[0, 500]}" : payload[:output]
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ # Fires `llm_call.platform_sdk` ActiveSupport::Notifications events with
9
+ # a payload extracted from a RubyConversations / RubyLLM-shaped chat.
10
+ #
11
+ # Apps that use the RubyLLM (or RubyConversations) gem call this from
12
+ # any LLM call site to get cost, token, model, input, and output
13
+ # captured in Langfuse without having to know the OTel attribute keys:
14
+ #
15
+ # PlatformSdk::Observability::Langfuse::RubyLLMAdapter.fire(
16
+ # conversation: conversation_manager,
17
+ # context: 'chat_response'
18
+ # )
19
+ #
20
+ # The adapter is RubyLLM-aware but does not require the RubyLLM gem at
21
+ # load time — `RubyLLM::Content` references are guarded by `defined?`,
22
+ # so apps that don't use RubyLLM can load the SDK without paying for
23
+ # this code path.
24
+ #
25
+ # Dedup: nested or repeated calls that share the same `chat.messages.last`
26
+ # object are skipped, so a single LLM response can't double-fire when
27
+ # multiple layers of error-handling each wrap the same yield. The
28
+ # dedup state is registered via `Langfuse.track_thread_local`, so
29
+ # `Traceable`-wrapped Sidekiq jobs get it cleared in their ensure
30
+ # block. Callers outside `Traceable` (Rails controllers, long-lived
31
+ # Puma threads) should invoke
32
+ # `Langfuse.clear_tracked_thread_locals!` at their request/operation
33
+ # boundary to avoid dedup state leaking between unrelated calls.
34
+ module RubyLLMAdapter
35
+ MAX_PROMPT_MESSAGES = 30
36
+ THREAD_LOCAL_KEY = :platform_sdk_langfuse_last_message_id
37
+
38
+ class << self
39
+ # Fire an `llm_call.platform_sdk` notification for a single LLM call.
40
+ # Accepts either a RubyConversations conversation (preferred — the
41
+ # adapter reads `conversation.chat` and `conversation.model_identifier`)
42
+ # or a raw RubyLLM::Chat. Returns nil — never raises.
43
+ def fire(context:, conversation: nil, chat: nil, error: nil)
44
+ payload = build_payload(context:, conversation:, chat:, error:)
45
+ return unless payload
46
+
47
+ ActiveSupport::Notifications.instrument(LLM_CALL_EVENT, payload)
48
+ rescue StandardError => e
49
+ OpenTelemetry.handle_error(
50
+ message: "RubyLLMAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
51
+ )
52
+ nil
53
+ end
54
+
55
+ private
56
+
57
+ # Build the notification payload from a RubyLLM-shaped conversation
58
+ # or chat. Returns nil when there's nothing to record (e.g. empty
59
+ # chat, dup of the last recorded response, or Langfuse disabled).
60
+ # Internal — exercise via `fire` and span assertions; the Hash
61
+ # shape is not a stable public contract.
62
+ def build_payload(context:, conversation: nil, chat: nil, error: nil)
63
+ return nil unless Langfuse.enabled?
64
+
65
+ chat ||= conversation&.chat
66
+ last = chat&.messages&.last
67
+ return nil if last.nil? && error.nil?
68
+ return nil if duplicate_llm_response?(last)
69
+
70
+ track_llm_response(last) if last
71
+
72
+ {
73
+ name: context || 'llm_call',
74
+ model: last&.model_id || conversation&.model_identifier,
75
+ input: chat ? serialize_input(chat) : nil,
76
+ output: last&.content,
77
+ usage: { input_tokens: last&.input_tokens, output_tokens: last&.output_tokens },
78
+ error:
79
+ }
80
+ end
81
+
82
+ def duplicate_llm_response?(last)
83
+ return false if last.nil?
84
+
85
+ Thread.current[THREAD_LOCAL_KEY] == last.object_id
86
+ end
87
+
88
+ def track_llm_response(last)
89
+ Langfuse.track_thread_local(THREAD_LOCAL_KEY)
90
+ Thread.current[THREAD_LOCAL_KEY] = last.object_id
91
+ end
92
+
93
+ def serialize_input(chat)
94
+ messages = chat&.messages
95
+ return nil if messages.nil? || messages.empty?
96
+
97
+ # Strip the assistant's response (last message) so input represents what was sent TO the LLM.
98
+ # Cap to the most-recent N messages to bound allocation on long tool-heavy conversations.
99
+ # `last(n)` returns a fresh array, so we can pop in place — one allocation instead of two.
100
+ prompt_messages = messages.last(MAX_PROMPT_MESSAGES + 1).tap(&:pop)
101
+ prompt_messages.map { |m| { role: m.role, content: extract_message_content(m) } }
102
+ end
103
+
104
+ def extract_message_content(message)
105
+ content = message.content
106
+ return content.to_s unless ruby_llm_content?(content)
107
+
108
+ content.text.to_s
109
+ end
110
+
111
+ def ruby_llm_content?(content)
112
+ defined?(::RubyLLM::Content) && content.is_a?(::RubyLLM::Content)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -37,7 +37,13 @@ module PlatformSdk
37
37
  )
38
38
  end
39
39
  config.before(:each) do
40
+ # Drain the BatchSpanProcessor before clearing — otherwise spans
41
+ # emitted by the previous example sit in the processor's queue
42
+ # and flush into the next example's view, producing
43
+ # order-dependent assertion failures.
44
+ PlatformSdk::Observability::Langfuse.configuration&.tracer_provider&.force_flush(timeout: 5)
40
45
  PlatformSdk::Observability::Langfuse::SpecSupport.exporter&.clear
46
+ PlatformSdk::Observability::Langfuse.clear_tracked_thread_locals!
41
47
  end
42
48
  config.include TestHelpers
43
49
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlatformSdk
4
+ module Observability
5
+ module Langfuse
6
+ # Mix into Sidekiq jobs (or anything running under a `Traceable` span)
7
+ # to record a meaningful summary of the work in the trace's Output
8
+ # column in Langfuse.
9
+ #
10
+ # Including classes implement `#langfuse_trace_output_attributes`
11
+ # returning a Hash; call `record_langfuse_trace_output` once the
12
+ # relevant instance state is settled (typically at the end of perform).
13
+ #
14
+ # class GenerateThingJob
15
+ # include Sidekiq::Job
16
+ # include PlatformSdk::Observability::Langfuse::Traceable
17
+ # include PlatformSdk::Observability::Langfuse::TraceSummarizable
18
+ #
19
+ # def perform(args)
20
+ # @thing = build_thing(args)
21
+ # record_langfuse_trace_output
22
+ # end
23
+ #
24
+ # private
25
+ #
26
+ # def langfuse_trace_output_attributes
27
+ # { thing_id: @thing&.id, status: @thing&.status }
28
+ # end
29
+ # end
30
+ module TraceSummarizable
31
+ private
32
+
33
+ def record_langfuse_trace_output
34
+ PlatformSdk::Observability::Langfuse.set_trace_output(langfuse_trace_output_attributes)
35
+ end
36
+
37
+ def langfuse_trace_output_attributes
38
+ raise NotImplementedError, "#{self.class} must implement #langfuse_trace_output_attributes"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,11 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Constants declared up-front so the files we require below can reference
4
+ # them at load time without a circular dependency.
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ # ActiveSupport::Notifications event name for LLM calls. Owned by the
9
+ # `Langfuse` namespace (rather than `NotificationSubscriber`) so the
10
+ # adapter can emit without requiring the subscriber to be loaded.
11
+ LLM_CALL_EVENT = 'llm_call.platform_sdk'
12
+
13
+ OWNED_THREAD_LOCALS_KEY = :platform_sdk_langfuse_owned_thread_locals
14
+ end
15
+ end
16
+ end
17
+
3
18
  require 'platform_sdk/observability/langfuse/coercions'
4
19
  require 'platform_sdk/observability/langfuse/configuration'
5
20
  require 'platform_sdk/observability/langfuse/null_span_exporter'
6
21
  require 'platform_sdk/observability/langfuse/recorder'
22
+ require 'platform_sdk/observability/langfuse/trace_summarizable'
7
23
  require 'platform_sdk/observability/langfuse/traceable'
8
24
  require 'platform_sdk/observability/langfuse/sidekiq_lifecycle'
25
+ require 'platform_sdk/observability/langfuse/notification_subscriber'
26
+ require 'platform_sdk/observability/langfuse/ruby_llm_adapter'
9
27
 
10
28
  module PlatformSdk
11
29
  module Observability
@@ -13,20 +31,16 @@ module PlatformSdk
13
31
  class Error < StandardError; end
14
32
  class ConfigurationError < Error; end
15
33
 
16
- OWNED_THREAD_LOCALS_KEY = :platform_sdk_langfuse_owned_thread_locals
17
-
18
34
  class << self
19
35
  attr_reader :configuration
20
36
 
21
- def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil)
37
+ def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil, autosubscribe: true)
22
38
  @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?
39
+ @configuration = Configuration.new(app_name:, environment:, prompt_label:, exporter:)
40
+ if @configuration.enabled?
41
+ SidekiqLifecycle.install!
42
+ NotificationSubscriber.install! if autosubscribe
43
+ end
30
44
  @configuration
31
45
  end
32
46
 
@@ -86,6 +100,7 @@ module PlatformSdk
86
100
  @configuration&.force_flush_and_shutdown
87
101
  @configuration = nil
88
102
  SidekiqLifecycle.reset!
103
+ NotificationSubscriber.reset!
89
104
  end
90
105
 
91
106
  # Register thread-local keys that should be cleared at the next
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
- MINOR = 28
5
+ MINOR = 29
6
6
  PATCH = 0
7
7
 
8
8
  VERSION = "#{PlatformSdk::MAJOR}.#{PlatformSdk::MINOR}.#{PlatformSdk::PATCH}"
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.28.0
4
+ version: 3.29.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-05-11 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -318,10 +318,13 @@ files:
318
318
  - lib/platform_sdk/observability/langfuse.rb
319
319
  - lib/platform_sdk/observability/langfuse/coercions.rb
320
320
  - lib/platform_sdk/observability/langfuse/configuration.rb
321
+ - lib/platform_sdk/observability/langfuse/notification_subscriber.rb
321
322
  - lib/platform_sdk/observability/langfuse/null_span_exporter.rb
322
323
  - lib/platform_sdk/observability/langfuse/recorder.rb
324
+ - lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb
323
325
  - lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb
324
326
  - lib/platform_sdk/observability/langfuse/spec_support.rb
327
+ - lib/platform_sdk/observability/langfuse/trace_summarizable.rb
325
328
  - lib/platform_sdk/observability/langfuse/traceable.rb
326
329
  - lib/platform_sdk/one_roster.rb
327
330
  - lib/platform_sdk/one_roster/client.rb