dspy-o11y-langfuse 1.0.1 → 1.1.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: b8332abbd0abfcb0cb30aea98cc91d45b827cd5a77d674b89bdb2fbb6fb6da32
4
- data.tar.gz: cd0006faf6a82befe4c67ad782fa9fca78088bfcd5d59e089772b6f7f05ef3d2
3
+ metadata.gz: 681cd7bc2d72f935e3e86ba63e8f7846ff385cf98c485e49ed8c67065eb5e254
4
+ data.tar.gz: '03792ca1ab58794ab07be57da2ff73c1f522212114b0fec18db8cea674d74e22'
5
5
  SHA512:
6
- metadata.gz: 76a3716dd937b61d5bc29fe65bc146de17e2b16acf9ad8467f0de2322be26d14bbc30eede060bf34503fa6644ec1f2832dafa0e1e41149fc10b490cc1ab2be3c
7
- data.tar.gz: 96c3283338d8ce2678efd17146ff3fafa5f1aba6ac0c65b7662069f580dbef44360cbac6b0f8788d68d910cb1aa9635a782db65dfb2a58d353b403b5df093296
6
+ metadata.gz: 5ae475d8c8d502806ea6c7edb647dc8378b9a68d35ebe9972717a0aac821a5d769e30147b0796efa7d94ead8e836e6f2169bfa0bb767384a14ad1190fc9bf12f
7
+ data.tar.gz: 3d9d35c62ead40d58d87ccf4eca2e0a23c811a864ed3e44f0c8ba23bab3c3e50e3406a7888012925c7f74af014ab9299ce33bfa3f6b66549424ca63c6f817413
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Gem Version](https://img.shields.io/gem/v/dspy)](https://rubygems.org/gems/dspy)
4
4
  [![Total Downloads](https://img.shields.io/gem/dt/dspy)](https://rubygems.org/gems/dspy)
5
5
  [![Build Status](https://img.shields.io/github/actions/workflow/status/vicentereig/dspy.rb/ruby.yml?branch=main&label=build)](https://github.com/vicentereig/dspy.rb/actions/workflows/ruby.yml)
6
- [![Documentation](https://img.shields.io/badge/docs-vicentereig.github.io%2Fdspy.rb-blue)](https://vicentereig.github.io/dspy.rb/)
6
+ [![Documentation](https://img.shields.io/badge/docs-oss.vicente.services%2Fdspy.rb-blue)](https://oss.vicente.services/dspy.rb/)
7
7
  [![Discord](https://img.shields.io/discord/1161519468141355160?label=discord&logo=discord&logoColor=white)](https://discord.gg/zWBhrMqn)
8
8
 
9
9
  > [!NOTE]
@@ -16,7 +16,7 @@
16
16
 
17
17
  **Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
18
18
 
19
- DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). It delivers structured LLM programming, prompt engineering, and context engineering in the language we love. Instead of wrestling with brittle prompt strings, you define typed signatures in idiomatic Ruby and compose workflows and agents that actually behave.
19
+ DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy paradigm](https://github.com/stanfordnlp/dspy). It delivers structured LLM programming, prompt engineering, and context engineering in the language we love. Instead of wrestling with brittle prompt strings, you define typed signatures in idiomatic Ruby and compose workflows and agents that actually behave.
20
20
 
21
21
  **Prompts are just functions.** Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you the programming approach pioneered by [dspy.ai](https://dspy.ai/): define modular signatures and let the framework deal with the messy bits.
22
22
 
@@ -102,6 +102,7 @@ DSPy.rb ships multiple gems from this monorepo so you can opt into features with
102
102
  | `dspy-openai` | Packages the OpenAI/OpenRouter/Ollama adapters plus the official SDK guardrails. Install whenever you call `openai/*`, `openrouter/*`, or `ollama/*`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/openai/README.md) | **Stable** (v1.0.0) |
103
103
  | `dspy-anthropic` | Claude adapters, streaming, and structured-output helpers behind the official `anthropic` SDK. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/anthropic/README.md) | **Stable** (v1.0.0) |
104
104
  | `dspy-gemini` | Gemini adapters with multimodal + tool-call support via `gemini-ai`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/gemini/README.md) | **Stable** (v1.0.0) |
105
+ | `dspy-ruby_llm` | Unified access to 12+ LLM providers (OpenAI, Anthropic, Gemini, Bedrock, Ollama, DeepSeek, etc.) via [RubyLLM](https://rubyllm.com). [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/ruby_llm/README.md) | **Stable** (v0.1.0) |
105
106
  | `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. (Add the gem or set `DSPY_WITH_CODE_ACT=1` before requiring `dspy/code_act`.) | **Stable** (v1.0.0) |
106
107
  | `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. (Toggle via `DSPY_WITH_DATASETS`.) | **Stable** (v1.0.0) |
107
108
  | `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. (Toggle via `DSPY_WITH_EVALS`.) | **Stable** (v1.0.0) |
@@ -247,13 +248,24 @@ DSPy.rb has gone from experimental to production-ready in three fast releases.
247
248
 
248
249
  ## Documentation
249
250
 
250
- 📖 **[Complete Documentation Website](https://vicentereig.github.io/dspy.rb/)**
251
+ 📖 **[Complete Documentation Website](https://oss.vicente.services/dspy.rb/)**
251
252
 
252
253
  ### LLM-Friendly Documentation
253
254
 
254
255
  For LLMs and AI assistants working with DSPy.rb:
255
- - **[llms.txt](https://vicentereig.github.io/dspy.rb/llms.txt)** - Concise reference optimized for LLMs
256
- - **[llms-full.txt](https://vicentereig.github.io/dspy.rb/llms-full.txt)** - Comprehensive API documentation
256
+ - **[llms.txt](https://oss.vicente.services/dspy.rb/llms.txt)** - Concise reference optimized for LLMs
257
+ - **[llms-full.txt](https://oss.vicente.services/dspy.rb/llms-full.txt)** - Comprehensive API documentation
258
+
259
+ ### Claude Skill
260
+
261
+ A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications with Claude Code or claude.ai.
262
+
263
+ **Claude Code:**
264
+ ```bash
265
+ git clone https://github.com/vicentereig/dspy-rb-skill ~/.claude/skills/dspy-rb
266
+ ```
267
+
268
+ **Claude.ai (Pro/Max):** Download the [skill as a ZIP](https://github.com/vicentereig/dspy-rb-skill/archive/refs/heads/main.zip) and upload via Settings > Skills.
257
269
 
258
270
  ### Getting Started
259
271
  - **[Installation & Setup](docs/src/getting-started/installation.md)** - Detailed installation and configuration
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'base64'
7
+
8
+ module DSPy
9
+ class Observability
10
+ module Adapters
11
+ module Langfuse
12
+ # Async exporter for sending scores to Langfuse REST API
13
+ # Uses a background thread to avoid blocking the main application
14
+ class ScoresExporter
15
+ extend T::Sig
16
+
17
+ DEFAULT_HOST = 'https://cloud.langfuse.com'
18
+ SCORES_ENDPOINT = '/api/public/scores'
19
+ DEFAULT_MAX_RETRIES = 3
20
+ DEFAULT_TIMEOUT = 10
21
+
22
+ attr_reader :host
23
+
24
+ sig do
25
+ params(
26
+ public_key: String,
27
+ secret_key: String,
28
+ host: String,
29
+ max_retries: Integer,
30
+ timeout: Integer
31
+ ).void
32
+ end
33
+ def initialize(
34
+ public_key:,
35
+ secret_key:,
36
+ host: DEFAULT_HOST,
37
+ max_retries: DEFAULT_MAX_RETRIES,
38
+ timeout: DEFAULT_TIMEOUT
39
+ )
40
+ @public_key = public_key
41
+ @secret_key = secret_key
42
+ @host = host.chomp('/')
43
+ @max_retries = max_retries
44
+ @timeout = timeout
45
+ @queue = Thread::Queue.new
46
+ @running = false
47
+ @worker_thread = nil
48
+ @subscription_id = nil
49
+ @mutex = Mutex.new
50
+ end
51
+
52
+ # Factory method that creates, starts, and subscribes to events
53
+ sig do
54
+ params(
55
+ public_key: String,
56
+ secret_key: String,
57
+ host: String,
58
+ max_retries: Integer,
59
+ timeout: Integer
60
+ ).returns(ScoresExporter)
61
+ end
62
+ def self.configure(
63
+ public_key:,
64
+ secret_key:,
65
+ host: DEFAULT_HOST,
66
+ max_retries: DEFAULT_MAX_RETRIES,
67
+ timeout: DEFAULT_TIMEOUT
68
+ )
69
+ exporter = new(
70
+ public_key: public_key,
71
+ secret_key: secret_key,
72
+ host: host,
73
+ max_retries: max_retries,
74
+ timeout: timeout
75
+ )
76
+ exporter.start
77
+ exporter.subscribe_to_events
78
+ exporter
79
+ end
80
+
81
+ sig { void }
82
+ def start
83
+ @mutex.synchronize do
84
+ return if @running
85
+
86
+ @running = true
87
+ @worker_thread = Thread.new { process_queue }
88
+ end
89
+ end
90
+
91
+ sig { returns(T::Boolean) }
92
+ def running?
93
+ @mutex.synchronize { @running }
94
+ end
95
+
96
+ sig { params(score_event: DSPy::Scores::ScoreEvent).void }
97
+ def export(score_event)
98
+ return unless running?
99
+
100
+ @queue.push(score_event)
101
+ end
102
+
103
+ sig { returns(Integer) }
104
+ def queue_size
105
+ @queue.size
106
+ end
107
+
108
+ sig { void }
109
+ def subscribe_to_events
110
+ @subscription_id = DSPy.events.subscribe('score.create') do |_name, attrs|
111
+ # Reconstruct ScoreEvent from event attributes
112
+ score_event = DSPy::Scores::ScoreEvent.new(
113
+ id: attrs[:score_id],
114
+ name: attrs[:score_name],
115
+ value: attrs[:score_value],
116
+ data_type: DSPy::Scores::DataType.deserialize(attrs[:score_data_type]),
117
+ comment: attrs[:score_comment],
118
+ trace_id: attrs[:trace_id],
119
+ observation_id: attrs[:observation_id]
120
+ )
121
+ export(score_event)
122
+ end
123
+ end
124
+
125
+ sig { params(timeout: Integer).void }
126
+ def shutdown(timeout: 5)
127
+ @mutex.synchronize do
128
+ return unless @running
129
+
130
+ @running = false
131
+
132
+ # Unsubscribe from events
133
+ DSPy.events.unsubscribe(@subscription_id) if @subscription_id
134
+ @subscription_id = nil
135
+
136
+ # Signal worker to stop
137
+ @queue.push(:stop)
138
+ end
139
+
140
+ # Wait for worker thread to finish
141
+ @worker_thread&.join(timeout)
142
+ end
143
+
144
+ private
145
+
146
+ sig { void }
147
+ def process_queue
148
+ while running? || !@queue.empty?
149
+ item = @queue.pop
150
+
151
+ break if item == :stop
152
+
153
+ begin
154
+ send_with_retry(item)
155
+ rescue StandardError => e
156
+ DSPy.log('scores.export_error', error: e.message, score_name: item.name)
157
+ end
158
+ end
159
+ end
160
+
161
+ sig { params(score_event: DSPy::Scores::ScoreEvent).void }
162
+ def send_with_retry(score_event)
163
+ retries = 0
164
+
165
+ begin
166
+ send_to_langfuse(score_event)
167
+ rescue StandardError => e
168
+ retries += 1
169
+ if retries <= @max_retries
170
+ sleep(exponential_backoff(retries))
171
+ retry
172
+ else
173
+ raise e
174
+ end
175
+ end
176
+ end
177
+
178
+ sig { params(attempt: Integer).returns(Float) }
179
+ def exponential_backoff(attempt)
180
+ # 0.1s, 0.2s, 0.4s, 0.8s... with jitter
181
+ base_delay = 0.1 * (2 ** (attempt - 1))
182
+ base_delay + rand * 0.1
183
+ end
184
+
185
+ sig { params(score_event: DSPy::Scores::ScoreEvent).void }
186
+ def send_to_langfuse(score_event)
187
+ uri = URI("#{@host}#{SCORES_ENDPOINT}")
188
+ payload = build_payload(score_event)
189
+
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = uri.scheme == 'https'
192
+ http.open_timeout = @timeout
193
+ http.read_timeout = @timeout
194
+
195
+ request = Net::HTTP::Post.new(uri.path)
196
+ request['Content-Type'] = 'application/json'
197
+ request['Authorization'] = "Basic #{auth_token}"
198
+ request.body = JSON.generate(payload)
199
+
200
+ response = http.request(request)
201
+
202
+ unless response.is_a?(Net::HTTPSuccess)
203
+ raise "Langfuse API error: #{response.code} - #{response.body}"
204
+ end
205
+
206
+ DSPy.log('scores.exported', score_name: score_event.name, score_id: score_event.id)
207
+ end
208
+
209
+ sig { params(score_event: DSPy::Scores::ScoreEvent).returns(T::Hash[Symbol, T.untyped]) }
210
+ def build_payload(score_event)
211
+ score_event.to_langfuse_payload
212
+ end
213
+
214
+ sig { returns(String) }
215
+ def auth_token
216
+ Base64.strict_encode64("#{@public_key}:#{@secret_key}")
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -3,7 +3,7 @@
3
3
  module DSPy
4
4
  module O11y
5
5
  module Langfuse
6
- VERSION = '1.0.1'
6
+ VERSION = '1.1.0'
7
7
  end
8
8
  end
9
9
  end
@@ -132,3 +132,6 @@ module DSPy
132
132
  end
133
133
 
134
134
  DSPy::Observability::Adapters::Langfuse.register!
135
+
136
+ # Load scores exporter for Langfuse
137
+ require_relative 'langfuse/scores_exporter'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy-o11y-langfuse
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
@@ -62,6 +62,7 @@ files:
62
62
  - LICENSE
63
63
  - README.md
64
64
  - lib/dspy/o11y/langfuse.rb
65
+ - lib/dspy/o11y/langfuse/scores_exporter.rb
65
66
  - lib/dspy/o11y/langfuse/version.rb
66
67
  homepage: https://github.com/vicentereig/dspy.rb
67
68
  licenses: