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 +4 -4
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -7
- data/lib/platform_sdk/observability/langfuse/bedrock_claude_adapter.rb +244 -0
- data/lib/platform_sdk/observability/langfuse/notification_subscriber.rb +127 -0
- data/lib/platform_sdk/observability/langfuse/openai_adapter.rb +87 -0
- data/lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb +118 -0
- data/lib/platform_sdk/observability/langfuse/spec_support.rb +6 -0
- data/lib/platform_sdk/observability/langfuse/trace_summarizable.rb +43 -0
- data/lib/platform_sdk/observability/langfuse.rb +27 -10
- data/lib/platform_sdk/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47af8235ee05e3c5a4e31ac2f6ad5a4936dcfd004ce0a0eae2737cf19ca59440
|
|
4
|
+
data.tar.gz: e6814730ac3a3187e89554d7b7dd3d99b629f3833f41a72de4f332f59a769a72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
data/lib/platform_sdk/version.rb
CHANGED
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.
|
|
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
|
+
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
|