strongmind-platform-sdk 3.28.0 → 3.30.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: 47af8235ee05e3c5a4e31ac2f6ad5a4936dcfd004ce0a0eae2737cf19ca59440
4
+ data.tar.gz: e6814730ac3a3187e89554d7b7dd3d99b629f3833f41a72de4f332f59a769a72
5
5
  SHA512:
6
- metadata.gz: 37074d6a5591e41ae57301530ffaed72b49bb21d07298ebd2c2434fcfe50cbd623f24b1fc6db4ee991b7b19c3eaa503014568a8e7b2548766265c8c90589dd0e
7
- data.tar.gz: a539e7cad181064b9d617c020241a356dafb96102bfb2d1a63398476885c400b6736dfad46ab0b941e314654f6181960a46552bde1a3556ab0ea281b6a97e347
6
+ metadata.gz: e47283eb744412ddd608074dd12c38433db14273eb55ba3df3c5f60737b02e687b2ecce1ad70463d01709be5c6914178512c9d416f15c403444e6bf241d0e4e6
7
+ data.tar.gz: 24aeba0a4181cdf7cfda05ad764c54d0348016232029e0db2fe7b4c1a0362c43c75618c14eeb3bce79b47cda9977f8f296808d499681bf327f8e2ebb2c3909a8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.30.0] - 2026-05-14
4
+
5
+ - Add `PlatformSdk::Observability::Langfuse::OpenAIAdapter` — wraps direct `ruby-openai` chat calls and fires `llm_call.platform_sdk` notifications with model, input messages, output content, and token usage extracted from OpenAI's response shape. Apps that hit OpenAI's API outside of RubyLLM now get the same Langfuse generation observations.
6
+ - Add `PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter` — wraps direct AWS Bedrock `invoke_model` calls for Anthropic Claude (Messages API on Bedrock). Supports both non-streaming (`with_observability`) and event-streaming (`with_streaming_observability { |collector| ... }`) modes; the streaming collector accumulates `content_block_delta` text and pulls final token counts from `message_stop`'s `amazon-bedrock-invocationMetrics`.
7
+ - Both adapters expose `with_observability` block helpers (success/failure fires + re-raises) and a bare `fire(...)` method for after-the-fact emission. Neither adapter requires its underlying gem (`ruby-openai`, `aws-sdk-bedrockruntime`) at load time — the SDK loads cleanly without them.
8
+
9
+ ## [3.29.0] - 2026-05-12
10
+
11
+ - 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.
12
+ - Auto-installed by `Langfuse.configure` (opt out with `autosubscribe: false`).
13
+ - 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).
14
+ - 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`.
15
+
3
16
  ## [3.28.0] - 2026-05-05
4
17
 
5
18
  - 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.30.0)
5
5
  asset_sync
6
6
  aws-sdk-cloudwatch
7
7
  aws-sdk-secretsmanager (~> 1.66)
@@ -177,12 +177,6 @@ GEM
177
177
  google-protobuf (4.33.6)
178
178
  bigdecimal
179
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
180
  googleapis-common-protos-types (1.22.0)
187
181
  google-protobuf (~> 4.26)
188
182
  hashdiff (1.1.0)
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+ require 'json'
5
+
6
+ module PlatformSdk
7
+ module Observability
8
+ module Langfuse
9
+ # Fires `llm_call.platform_sdk` notifications for Anthropic Claude calls
10
+ # made directly through the AWS Bedrock SDK (rather than via RubyLLM).
11
+ # Two entry points cover the two Bedrock invocation modes:
12
+ #
13
+ # # Non-streaming (`bedrock_client.invoke_model(payload)`)
14
+ # PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter.with_observability(
15
+ # payload: { model_id: 'us.anthropic.claude-sonnet-4-6',
16
+ # body: JSON.dump(anthropic_messages_payload) },
17
+ # context: 'generate_outline'
18
+ # ) { bedrock_client.invoke_model(payload) }
19
+ #
20
+ # # Streaming (`bedrock_client.invoke_model_with_response_stream`)
21
+ # PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter.with_streaming_observability(
22
+ # payload: { model_id: ..., body: JSON.dump(...) },
23
+ # context: 'chat_stream'
24
+ # ) do |collector|
25
+ # bedrock_client.invoke_model_with_response_stream(
26
+ # payload.merge(event_stream_handler: proc do |stream|
27
+ # stream.on_chunk_event do |event|
28
+ # collector.observe(event)
29
+ # # ...the caller's own chunk handling
30
+ # end
31
+ # end)
32
+ # )
33
+ # end
34
+ #
35
+ # The streaming collector accumulates text from `content_block_delta`
36
+ # events and pulls the final `input_tokens`/`output_tokens` from the
37
+ # `message_stop` event's `amazon-bedrock-invocationMetrics`. The
38
+ # notification fires once after the block returns.
39
+ #
40
+ # No hard dependency on the AWS SDK — this adapter only reads the
41
+ # documented JSON shape of Anthropic's Messages API on Bedrock.
42
+ module BedrockClaudeAdapter
43
+ class << self
44
+ def with_observability(payload:, context:)
45
+ response = yield
46
+ fire(payload:, response:, context:)
47
+ response
48
+ rescue StandardError => e
49
+ fire(payload:, response: nil, context:, error: e)
50
+ raise
51
+ end
52
+
53
+ def with_streaming_observability(payload:, context:)
54
+ collector = StreamCollector.new
55
+ result = yield collector
56
+ fire_from_collector(payload:, collector:, context:)
57
+ result
58
+ rescue StandardError => e
59
+ fire_from_collector(payload:, collector:, context:, error: e)
60
+ raise
61
+ end
62
+
63
+ # After-the-fact emission for non-streaming calls. Caller can use
64
+ # this if they want to invoke Bedrock outside of the block helper
65
+ # and emit observability separately.
66
+ def fire(payload:, response:, context:, error: nil)
67
+ attrs = decode_response(response)
68
+ notify(payload:, output: attrs[:output], usage: attrs[:usage], context:, error:)
69
+ rescue StandardError => e
70
+ OpenTelemetry.handle_error(
71
+ message: "BedrockClaudeAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
72
+ )
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def fire_from_collector(payload:, collector:, context:, error: nil)
79
+ usage = collector.usage
80
+ notify(
81
+ payload:,
82
+ output: collector.text.empty? ? nil : collector.text,
83
+ usage: usage.values.any? ? usage : {},
84
+ context:,
85
+ error:
86
+ )
87
+ end
88
+
89
+ def notify(payload:, output:, usage:, context:, error:)
90
+ built = build_payload(payload:, output:, usage:, context:, error:)
91
+ return unless built
92
+
93
+ ActiveSupport::Notifications.instrument(LLM_CALL_EVENT, built)
94
+ rescue StandardError => e
95
+ OpenTelemetry.handle_error(
96
+ message: "BedrockClaudeAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
97
+ )
98
+ nil
99
+ end
100
+
101
+ def build_payload(payload:, output:, usage:, context:, error:)
102
+ return nil unless Langfuse.enabled?
103
+
104
+ # Tolerate non-Hash payloads (some callers pass `to_json`'d strings
105
+ # in tests) — extract only when the shape is what we expect.
106
+ payload = payload.is_a?(Hash) ? payload : {}
107
+ body = decode_body(payload[:body] || payload['body'])
108
+
109
+ {
110
+ name: context || 'llm_call',
111
+ model: payload[:model_id] || payload['model_id'] || body[:model] || body['model'],
112
+ input: body[:messages] || body['messages'],
113
+ output:,
114
+ usage: usage || {},
115
+ provider: 'aws.bedrock',
116
+ error:
117
+ }
118
+ end
119
+
120
+ def decode_body(body)
121
+ return body if body.is_a?(Hash)
122
+ return {} if body.nil?
123
+
124
+ JSON.parse(body.to_s)
125
+ rescue JSON::ParserError
126
+ {}
127
+ end
128
+
129
+ # Pulls `output` (concatenated text from content blocks) and `usage`
130
+ # (`input_tokens`/`output_tokens`) from a non-streaming Anthropic
131
+ # Messages-on-Bedrock response. Tolerant of both `response.body`
132
+ # (AWS SDK Seahorse) and plain Hash shapes for ease of testing.
133
+ def decode_response(response)
134
+ return { output: nil, usage: {} } if response.nil?
135
+
136
+ body = response.respond_to?(:body) ? response.body : response
137
+ # Rewind after read so the caller can re-read response.body
138
+ # downstream — Bedrock's Seahorse response wraps a StringIO that
139
+ # would otherwise be consumed by our instrumentation.
140
+ if body.respond_to?(:read)
141
+ raw = body.read
142
+ body.rewind if body.respond_to?(:rewind)
143
+ body = raw
144
+ end
145
+ parsed = body.is_a?(Hash) ? body : safe_parse_json(body.to_s)
146
+
147
+ { output: extract_text(parsed), usage: extract_usage(parsed) }
148
+ end
149
+
150
+ def safe_parse_json(str)
151
+ JSON.parse(str)
152
+ rescue JSON::ParserError
153
+ {}
154
+ end
155
+
156
+ def extract_text(parsed)
157
+ content = parsed['content'] || parsed[:content]
158
+ return nil unless content.is_a?(Array)
159
+
160
+ content.filter_map { |block| block['text'] || block[:text] }.join
161
+ end
162
+
163
+ def extract_usage(parsed)
164
+ usage = parsed['usage'] || parsed[:usage] || {}
165
+ {
166
+ input_tokens: usage['input_tokens'] || usage[:input_tokens],
167
+ output_tokens: usage['output_tokens'] || usage[:output_tokens]
168
+ }
169
+ end
170
+ end
171
+
172
+ # Accumulates streaming-response state so the adapter can emit a
173
+ # single observation after the stream ends.
174
+ #
175
+ # Bedrock's invoke_model_with_response_stream yields events whose
176
+ # `bytes` is JSON. For Anthropic on Bedrock the relevant event types
177
+ # are:
178
+ #
179
+ # * `content_block_delta` — incremental text in `delta.text`
180
+ # * `message_delta` — top-level `usage.output_tokens` (final count)
181
+ # * `message_stop` — Bedrock adds `amazon-bedrock-invocationMetrics`
182
+ # here with `inputTokenCount` and `outputTokenCount`
183
+ #
184
+ # Both message_delta and message_stop are emitted on success; we
185
+ # prefer message_stop's metrics when both are present.
186
+ class StreamCollector
187
+ attr_reader :text
188
+
189
+ def initialize
190
+ @text = +''
191
+ @input_tokens = nil
192
+ @output_tokens = nil
193
+ end
194
+
195
+ def usage
196
+ { input_tokens: @input_tokens, output_tokens: @output_tokens }
197
+ end
198
+
199
+ def observe(event)
200
+ parsed = parse_event(event)
201
+ return if parsed.nil?
202
+
203
+ case parsed['type']
204
+ when 'message_start'
205
+ # Anthropic emits initial input_tokens here. Used as a
206
+ # fallback when Bedrock's message_stop invocationMetrics
207
+ # are absent (future models, guardrail truncations, etc.).
208
+ start_usage = parsed.dig('message', 'usage') || {}
209
+ @input_tokens ||= start_usage['input_tokens']
210
+ when 'content_block_delta'
211
+ delta_text = parsed.dig('delta', 'text')
212
+ @text << delta_text if delta_text
213
+ when 'message_delta'
214
+ # message_delta usage is cumulative across the (possibly many)
215
+ # message_delta events Anthropic emits, so the latest value
216
+ # wins. See https://docs.anthropic.com/en/api/messages-streaming
217
+ usage = parsed['usage'] || {}
218
+ @output_tokens = usage['output_tokens'] if usage['output_tokens']
219
+ when 'message_stop'
220
+ metrics = parsed['amazon-bedrock-invocationMetrics'] || {}
221
+ @input_tokens = metrics['inputTokenCount'] if metrics['inputTokenCount']
222
+ @output_tokens = metrics['outputTokenCount'] if metrics['outputTokenCount']
223
+ end
224
+ rescue StandardError => e
225
+ OpenTelemetry.handle_error(
226
+ message: "BedrockClaudeAdapter::StreamCollector#observe failed: #{e.class}: #{e.message[0, 200]}"
227
+ )
228
+ end
229
+
230
+ private
231
+
232
+ def parse_event(event)
233
+ bytes = event.respond_to?(:bytes) ? event.bytes : event
234
+ return nil if bytes.nil?
235
+
236
+ JSON.parse(bytes.to_s)
237
+ rescue JSON::ParserError
238
+ nil
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -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,87 @@
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 `ruby-openai` chat call. Apps that hit
10
+ # OpenAI's chat completions API directly (rather than via RubyLLM) call
11
+ # `with_observability` to get cost, tokens, model, input, and output
12
+ # captured in Langfuse:
13
+ #
14
+ # PlatformSdk::Observability::Langfuse::OpenAIAdapter.with_observability(
15
+ # parameters: { model: 'gpt-4o', messages: [...], ... },
16
+ # context: 'amend_json'
17
+ # ) do
18
+ # OpenAI::Client.new.chat(parameters: parameters)
19
+ # end
20
+ #
21
+ # The block runs the actual LLM call and its return value is forwarded
22
+ # back to the caller. On success a generation observation is recorded
23
+ # with model/usage from the response. On raise a failure observation
24
+ # is recorded and the exception is re-raised unchanged.
25
+ #
26
+ # No hard dependency on the `ruby-openai` gem — this adapter only
27
+ # touches the plain-Hash response shape OpenAI's API returns.
28
+ module OpenAIAdapter
29
+ class << self
30
+ # Wrap an `OpenAI::Client#chat` call. Forwards the block's return
31
+ # value. On success: fires success notification with model/input/
32
+ # output/usage extracted from `parameters` and the returned response.
33
+ # On raise: fires failure notification with `error:` set, re-raises.
34
+ def with_observability(parameters:, context:)
35
+ response = yield
36
+ fire(parameters:, response:, context:)
37
+ response
38
+ rescue StandardError => e
39
+ fire(parameters:, response: nil, context:, error: e)
40
+ raise
41
+ end
42
+
43
+ # Fire a single `llm_call.platform_sdk` notification. Useful when
44
+ # the caller has already invoked OpenAI and just wants to record
45
+ # the observation after the fact.
46
+ def fire(parameters:, response:, context:, error: nil)
47
+ payload = build_payload(parameters:, response:, context:, error:)
48
+ return unless payload
49
+
50
+ ActiveSupport::Notifications.instrument(LLM_CALL_EVENT, payload)
51
+ rescue StandardError => e
52
+ OpenTelemetry.handle_error(
53
+ message: "OpenAIAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
54
+ )
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def build_payload(parameters:, response:, context:, error:)
61
+ return nil unless Langfuse.enabled?
62
+
63
+ parameters ||= {}
64
+ choice = response.is_a?(Hash) ? response.dig('choices', 0, 'message') : nil
65
+ usage = response.is_a?(Hash) ? response['usage'] : nil
66
+
67
+ {
68
+ name: context || 'llm_call',
69
+ model: parameters[:model] || parameters['model'],
70
+ input: parameters[:messages] || parameters['messages'],
71
+ output: choice && choice['content'],
72
+ # ruby-openai responses use prompt_tokens / completion_tokens; the
73
+ # input_tokens / output_tokens fallbacks cover OpenAI-compatible
74
+ # proxies and any future rename of those fields.
75
+ usage: {
76
+ input_tokens: usage && (usage['prompt_tokens'] || usage['input_tokens']),
77
+ output_tokens: usage && (usage['completion_tokens'] || usage['output_tokens'])
78
+ },
79
+ provider: 'openai',
80
+ error:
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ 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,31 @@
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'
27
+ require 'platform_sdk/observability/langfuse/openai_adapter'
28
+ require 'platform_sdk/observability/langfuse/bedrock_claude_adapter'
9
29
 
10
30
  module PlatformSdk
11
31
  module Observability
@@ -13,20 +33,16 @@ module PlatformSdk
13
33
  class Error < StandardError; end
14
34
  class ConfigurationError < Error; end
15
35
 
16
- OWNED_THREAD_LOCALS_KEY = :platform_sdk_langfuse_owned_thread_locals
17
-
18
36
  class << self
19
37
  attr_reader :configuration
20
38
 
21
- def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil)
39
+ def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil, autosubscribe: true)
22
40
  @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?
41
+ @configuration = Configuration.new(app_name:, environment:, prompt_label:, exporter:)
42
+ if @configuration.enabled?
43
+ SidekiqLifecycle.install!
44
+ NotificationSubscriber.install! if autosubscribe
45
+ end
30
46
  @configuration
31
47
  end
32
48
 
@@ -86,6 +102,7 @@ module PlatformSdk
86
102
  @configuration&.force_flush_and_shutdown
87
103
  @configuration = nil
88
104
  SidekiqLifecycle.reset!
105
+ NotificationSubscriber.reset!
89
106
  end
90
107
 
91
108
  # 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 = 30
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.30.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-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -316,12 +316,17 @@ files:
316
316
  - lib/platform_sdk/logging/pii_formatter.rb
317
317
  - lib/platform_sdk/observability.rb
318
318
  - lib/platform_sdk/observability/langfuse.rb
319
+ - lib/platform_sdk/observability/langfuse/bedrock_claude_adapter.rb
319
320
  - lib/platform_sdk/observability/langfuse/coercions.rb
320
321
  - lib/platform_sdk/observability/langfuse/configuration.rb
322
+ - lib/platform_sdk/observability/langfuse/notification_subscriber.rb
321
323
  - lib/platform_sdk/observability/langfuse/null_span_exporter.rb
324
+ - lib/platform_sdk/observability/langfuse/openai_adapter.rb
322
325
  - lib/platform_sdk/observability/langfuse/recorder.rb
326
+ - lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb
323
327
  - lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb
324
328
  - lib/platform_sdk/observability/langfuse/spec_support.rb
329
+ - lib/platform_sdk/observability/langfuse/trace_summarizable.rb
325
330
  - lib/platform_sdk/observability/langfuse/traceable.rb
326
331
  - lib/platform_sdk/one_roster.rb
327
332
  - lib/platform_sdk/one_roster/client.rb