strongmind-platform-sdk 3.27.5 → 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 +4 -4
- data/CHANGELOG.md +17 -0
- data/Gemfile.lock +44 -9
- data/lib/platform_sdk/observability/langfuse/coercions.rb +25 -0
- data/lib/platform_sdk/observability/langfuse/configuration.rb +150 -0
- data/lib/platform_sdk/observability/langfuse/notification_subscriber.rb +127 -0
- data/lib/platform_sdk/observability/langfuse/null_span_exporter.rb +30 -0
- data/lib/platform_sdk/observability/langfuse/recorder.rb +97 -0
- data/lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb +118 -0
- data/lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb +33 -0
- data/lib/platform_sdk/observability/langfuse/spec_support.rb +94 -0
- data/lib/platform_sdk/observability/langfuse/trace_summarizable.rb +43 -0
- data/lib/platform_sdk/observability/langfuse/traceable.rb +105 -0
- data/lib/platform_sdk/observability/langfuse.rb +138 -0
- data/lib/platform_sdk/observability.rb +8 -0
- data/lib/platform_sdk/version.rb +2 -2
- data/lib/platform_sdk.rb +1 -0
- metadata +42 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 799fe97b77fb105775c8b846e6916380be6845209a5157633d832186ae81bd25
|
|
4
|
+
data.tar.gz: ee0bbab328e6e27bcf72a5e634535f9ed0ab91bb57976b94a9c1fd24fac268f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7080031758db9d9bd0894dcc2b8054df0377f1bc33a6da0ba987f7be9888c343cb0e2feb83d9d811c95114e1891ff4a62c1cde7d445333b35a5bcbf6a2b4007a
|
|
7
|
+
data.tar.gz: c8e8bb3e06b1b6808cbc02c50664921dd6850520050cf5e0191a4bc01b3e2e4357bb8d0fa4c4edd14a95db1134d351a5ca3fbd1105243eb0cfd317ac85955f80
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
10
|
+
## [3.28.0] - 2026-05-05
|
|
11
|
+
|
|
12
|
+
- Add `PlatformSdk::Observability::Langfuse` module providing OpenTelemetry-based tracing exported to Langfuse Cloud via OTLP.
|
|
13
|
+
- `TracerProvider` is isolated (not installed globally) and coexists safely with `ddtrace`.
|
|
14
|
+
- Add `Traceable` Sidekiq concern (`include PlatformSdk::Observability::Langfuse::Traceable`) that wraps `perform` in a parent span.
|
|
15
|
+
- Add `record_generation` module method emitting `gen_ai.*` child spans inside an active trace.
|
|
16
|
+
- Add `NullSpanExporter` for use in tests (no network calls).
|
|
17
|
+
- Sidekiq shutdown flush hook is installed automatically when `configure` is called with valid keys.
|
|
18
|
+
- Add runtime dependencies: `opentelemetry-sdk`, `opentelemetry-exporter-otlp`.
|
|
19
|
+
|
|
3
20
|
## [0.1.0] - 2022-05-13
|
|
4
21
|
|
|
5
22
|
- Initial release
|
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.29.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.
|
|
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.
|
|
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.
|
|
157
|
+
fog-aws (3.33.2)
|
|
154
158
|
base64 (>= 0.2, < 0.4)
|
|
155
159
|
fog-core (~> 2.6)
|
|
156
160
|
fog-json (~> 1.1)
|
|
@@ -160,16 +164,27 @@ GEM
|
|
|
160
164
|
excon (~> 1.0)
|
|
161
165
|
formatador (>= 0.2, < 2.0)
|
|
162
166
|
mime-types
|
|
163
|
-
fog-json (1.
|
|
167
|
+
fog-json (1.3.0)
|
|
164
168
|
fog-core
|
|
165
169
|
multi_json (~> 1.10)
|
|
166
170
|
fog-xml (0.1.5)
|
|
167
171
|
fog-core
|
|
168
172
|
nokogiri (>= 1.5.11, < 2.0.0)
|
|
169
|
-
formatador (1.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.
|
|
216
|
+
mime-types-data (3.2026.0414)
|
|
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.
|
|
224
|
+
net-imap (0.6.4)
|
|
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.
|
|
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,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,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,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
|
|
@@ -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,94 @@
|
|
|
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
|
+
# 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)
|
|
45
|
+
PlatformSdk::Observability::Langfuse::SpecSupport.exporter&.clear
|
|
46
|
+
PlatformSdk::Observability::Langfuse.clear_tracked_thread_locals!
|
|
47
|
+
end
|
|
48
|
+
config.include TestHelpers
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Configure the Langfuse module with a NullSpanExporter. Separate from
|
|
53
|
+
# `install!` so it can be invoked directly in specs that need to test
|
|
54
|
+
# SpecSupport itself, or by apps that drive their own lifecycle.
|
|
55
|
+
def self.apply!(app_name:, environment: DEFAULT_TEST_ENVIRONMENT)
|
|
56
|
+
ENV['LANGFUSE_PUBLIC_KEY'] ||= DEFAULT_TEST_PUBLIC_KEY
|
|
57
|
+
ENV['LANGFUSE_SECRET_KEY'] ||= DEFAULT_TEST_SECRET_KEY
|
|
58
|
+
|
|
59
|
+
PlatformSdk::Observability::Langfuse.reset!
|
|
60
|
+
PlatformSdk::Observability::Langfuse.configure(
|
|
61
|
+
app_name: app_name,
|
|
62
|
+
environment: environment,
|
|
63
|
+
exporter: NullSpanExporter.new
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The currently-configured NullSpanExporter, or nil if `install!` was
|
|
68
|
+
# not called or the configuration was reset.
|
|
69
|
+
def self.exporter
|
|
70
|
+
config = PlatformSdk::Observability::Langfuse.configuration
|
|
71
|
+
return nil unless config
|
|
72
|
+
|
|
73
|
+
config.exporter
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
module TestHelpers
|
|
77
|
+
# Force-flush the BatchSpanProcessor and return every span the
|
|
78
|
+
# NullSpanExporter has captured during the current example. Use this
|
|
79
|
+
# to assert that a code path produced the expected spans.
|
|
80
|
+
#
|
|
81
|
+
# Example:
|
|
82
|
+
# spans = langfuse_exported_spans
|
|
83
|
+
# parent = spans.find { |s| s.name == 'MyJob' }
|
|
84
|
+
# expect(parent.attributes['langfuse.trace.tags']).to include('test')
|
|
85
|
+
def langfuse_exported_spans
|
|
86
|
+
config = PlatformSdk::Observability::Langfuse.configuration
|
|
87
|
+
config.tracer_provider.force_flush(timeout: 5)
|
|
88
|
+
PlatformSdk::Observability::Langfuse::SpecSupport.exporter.exported_spans
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
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
|
|
@@ -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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
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
|
+
|
|
18
|
+
require 'platform_sdk/observability/langfuse/coercions'
|
|
19
|
+
require 'platform_sdk/observability/langfuse/configuration'
|
|
20
|
+
require 'platform_sdk/observability/langfuse/null_span_exporter'
|
|
21
|
+
require 'platform_sdk/observability/langfuse/recorder'
|
|
22
|
+
require 'platform_sdk/observability/langfuse/trace_summarizable'
|
|
23
|
+
require 'platform_sdk/observability/langfuse/traceable'
|
|
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
|
+
|
|
28
|
+
module PlatformSdk
|
|
29
|
+
module Observability
|
|
30
|
+
module Langfuse
|
|
31
|
+
class Error < StandardError; end
|
|
32
|
+
class ConfigurationError < Error; end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
attr_reader :configuration
|
|
36
|
+
|
|
37
|
+
def configure(app_name:, environment: nil, prompt_label: nil, exporter: nil, autosubscribe: true)
|
|
38
|
+
@configuration&.force_flush_and_shutdown
|
|
39
|
+
@configuration = Configuration.new(app_name:, environment:, prompt_label:, exporter:)
|
|
40
|
+
if @configuration.enabled?
|
|
41
|
+
SidekiqLifecycle.install!
|
|
42
|
+
NotificationSubscriber.install! if autosubscribe
|
|
43
|
+
end
|
|
44
|
+
@configuration
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enabled?
|
|
48
|
+
!@configuration.nil? && @configuration.enabled?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tracer
|
|
52
|
+
return nil unless enabled?
|
|
53
|
+
|
|
54
|
+
@configuration.tracer
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record_generation(**kwargs)
|
|
58
|
+
Recorder.record_generation(**kwargs)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Set the trace-level output on the active parent span. Application
|
|
62
|
+
# code calls this from inside a Traceable job (or any block running
|
|
63
|
+
# under an active span) to record a meaningful summary of the job's
|
|
64
|
+
# result.
|
|
65
|
+
#
|
|
66
|
+
# Why this is opt-in rather than auto-captured from `perform`'s return
|
|
67
|
+
# value: Sidekiq's `perform` return is incidental — it's often nil, an
|
|
68
|
+
# AR transaction callback array, or otherwise unrelated to anything a
|
|
69
|
+
# human reading a trace would find useful. Only the application knows
|
|
70
|
+
# what's meaningful (e.g. a generated record's ID).
|
|
71
|
+
#
|
|
72
|
+
# Example:
|
|
73
|
+
# class MyJob
|
|
74
|
+
# include Sidekiq::Job
|
|
75
|
+
# include PlatformSdk::Observability::Langfuse::Traceable
|
|
76
|
+
#
|
|
77
|
+
# def perform(args)
|
|
78
|
+
# component = generate_component(args)
|
|
79
|
+
# PlatformSdk::Observability::Langfuse.set_trace_output(
|
|
80
|
+
# { component_id: component.id, status: component.status }
|
|
81
|
+
# )
|
|
82
|
+
# end
|
|
83
|
+
# end
|
|
84
|
+
#
|
|
85
|
+
# No-ops when the SDK is disabled or no span is currently active.
|
|
86
|
+
# Never raises — observability failures must not break callers.
|
|
87
|
+
def set_trace_output(value)
|
|
88
|
+
return unless enabled?
|
|
89
|
+
|
|
90
|
+
span = OpenTelemetry::Trace.current_span
|
|
91
|
+
return unless span.context.valid?
|
|
92
|
+
|
|
93
|
+
span.set_attribute('langfuse.trace.output', Recorder.stringify(value))
|
|
94
|
+
rescue StandardError, SystemStackError => e
|
|
95
|
+
OpenTelemetry.handle_error(message: "Langfuse set_trace_output failed: #{e.class}: #{e.message[0, 200]}")
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reset!
|
|
100
|
+
@configuration&.force_flush_and_shutdown
|
|
101
|
+
@configuration = nil
|
|
102
|
+
SidekiqLifecycle.reset!
|
|
103
|
+
NotificationSubscriber.reset!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Register thread-local keys that should be cleared at the next
|
|
107
|
+
# Traceable job boundary. Consumers call this when they store
|
|
108
|
+
# per-trace state on `Thread.current` (e.g. the ID of the last
|
|
109
|
+
# recorded message) so the value can't leak into the next job
|
|
110
|
+
# that lands on this Sidekiq thread.
|
|
111
|
+
#
|
|
112
|
+
# Example:
|
|
113
|
+
# PlatformSdk::Observability::Langfuse.track_thread_local(:my_app_last_message_id)
|
|
114
|
+
# Thread.current[:my_app_last_message_id] = msg.id
|
|
115
|
+
#
|
|
116
|
+
# Compared to a name-prefix convention, this requires explicit
|
|
117
|
+
# opt-in (so consumers can't accidentally have unrelated state
|
|
118
|
+
# zeroed) and is O(owned keys) instead of O(all thread locals).
|
|
119
|
+
def track_thread_local(*keys)
|
|
120
|
+
list = (Thread.current[OWNED_THREAD_LOCALS_KEY] ||= [])
|
|
121
|
+
keys.each { |k| list << k unless list.include?(k) }
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Clear every thread-local registered via `track_thread_local` on
|
|
126
|
+
# this thread, then drop the registry itself. Called from the
|
|
127
|
+
# Traceable wrapper's `ensure` block.
|
|
128
|
+
def clear_tracked_thread_locals!
|
|
129
|
+
owned = Thread.current[OWNED_THREAD_LOCALS_KEY]
|
|
130
|
+
return unless owned
|
|
131
|
+
|
|
132
|
+
owned.each { |k| Thread.current[k] = nil }
|
|
133
|
+
Thread.current[OWNED_THREAD_LOCALS_KEY] = nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/platform_sdk/version.rb
CHANGED
data/lib/platform_sdk.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.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-
|
|
11
|
+
date: 2026-05-13 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,18 @@ 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/notification_subscriber.rb
|
|
322
|
+
- lib/platform_sdk/observability/langfuse/null_span_exporter.rb
|
|
323
|
+
- lib/platform_sdk/observability/langfuse/recorder.rb
|
|
324
|
+
- lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb
|
|
325
|
+
- lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb
|
|
326
|
+
- lib/platform_sdk/observability/langfuse/spec_support.rb
|
|
327
|
+
- lib/platform_sdk/observability/langfuse/trace_summarizable.rb
|
|
328
|
+
- lib/platform_sdk/observability/langfuse/traceable.rb
|
|
289
329
|
- lib/platform_sdk/one_roster.rb
|
|
290
330
|
- lib/platform_sdk/one_roster/client.rb
|
|
291
331
|
- lib/platform_sdk/ops_genie.rb
|