dspy-o11y 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9d96b5a425cbf761e71df7be01407396fee6dfc8f840861ff6d041f7aeaecab9
4
+ data.tar.gz: 139dcc9aff3e2e4a63e168c2d8d58ab6ce4f675b8de766585e4631d6a3bb475b
5
+ SHA512:
6
+ metadata.gz: 976d74d47091190adf36bab23587219a3f83efbeeba41bf5dcc330737b0f91ddb96223425e17d251127d4ac2432c6f87e19fc86533b6576cb56d3ba5a537adf9
7
+ data.tar.gz: 975621400a7c677a44b499c741d7f2be65316f3ec91473c18d2ea8fda1291ba6b2b4e2c82178a24e854039df3ee56a70c611f353782f78382d0dd2b88d0a61e8
data/LICENSE ADDED
@@ -0,0 +1,45 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vicente Services SL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This project is a Ruby port of the original Python [DSPy library](https://github.com/stanfordnlp/dspy), which is licensed under the MIT License:
24
+
25
+ MIT License
26
+
27
+ Copyright (c) 2023 Stanford Future Data Systems
28
+
29
+ Permission is hereby granted, free of charge, to any person obtaining a copy
30
+ of this software and associated documentation files (the "Software"), to deal
31
+ in the Software without restriction, including without limitation the rights
32
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
33
+ copies of the Software, and to permit persons to whom the Software is
34
+ furnished to do so, subject to the following conditions:
35
+
36
+ The above copyright notice and this permission notice shall be included in all
37
+ copies or substantial portions of the Software.
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
41
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
42
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
43
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
44
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
45
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # DSPy.rb
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/dspy)](https://rubygems.org/gems/dspy)
4
+ [![Total Downloads](https://img.shields.io/gem/dt/dspy)](https://rubygems.org/gems/dspy)
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/)
7
+
8
+ > [!NOTE]
9
+ > The core Prompt Engineering Framework is production-ready with
10
+ > comprehensive documentation. I am focusing now on educational content on systematic Prompt Optimization and Context Engineering.
11
+ > Your feedback is invaluable. if you encounter issues, please open an [issue](https://github.com/vicentereig/dspy.rb/issues). If you have suggestions, open a [new thread](https://github.com/vicentereig/dspy.rb/discussions).
12
+ >
13
+ > If you want to contribute, feel free to reach out to me to coordinate efforts: hey at vicente.services
14
+ >
15
+ > And, yes, this is 100% a legit project. :)
16
+
17
+
18
+ **Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
19
+
20
+ The Ruby framework for programming with large language models. DSPy.rb brings structured LLM programming to Ruby developers, programmatic Prompt Engineering and Context Engineering.
21
+ Instead of wrestling with prompt strings and parsing responses, you define typed signatures using idiomatic Ruby to compose and decompose AI Worklows and AI Agents.
22
+
23
+ **Prompts are the just Functions.** Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you
24
+ the programming approach pioneered by [dspy.ai](https://dspy.ai/): instead of crafting fragile prompts, you define modular
25
+ signatures and let the framework handle the messy details.
26
+
27
+ DSPy.rb is an idiomatic Ruby surgical port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). While implementing
28
+ the core concepts of signatures, predictors, and the main optimization algorithms from the original Python library, DSPy.rb embraces Ruby
29
+ conventions and adds Ruby-specific innovations like Sorbet-base Typed system, ReAct loops, and production-ready integrations like non-blocking Open Telemetry Instrumentation.
30
+
31
+ **What you get?** Ruby LLM applications that actually scale and don't break when you sneeze.
32
+
33
+ Check the [examples](examples/) and take them for a spin!
34
+
35
+ ## Your First DSPy Program
36
+ ### Installation
37
+
38
+ Add to your Gemfile:
39
+
40
+ ```ruby
41
+ gem 'dspy'
42
+ ```
43
+
44
+ and
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ### Optional Sibling Gems
51
+
52
+ DSPy.rb ships multiple gems from this monorepo so you only install what you need. Add these alongside `dspy`:
53
+
54
+ | Gem | Description | Status |
55
+ | --- | --- | --- |
56
+ | `dspy-schema` | Exposes `DSPy::TypeSystem::SorbetJsonSchema` for downstream reuse. | **Stable** (v1.0.0) |
57
+ | `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. | Preview (0.x) |
58
+ | `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. | Preview (0.x) |
59
+ | `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. | Preview (0.x) |
60
+ | `dspy-miprov2` | Bayesian optimization + Gaussian Process backend for the MIPROv2 teleprompter. | Preview (0.x) |
61
+ | `dspy-gepa` | `DSPy::Teleprompt::GEPA`, reflection loops, experiment tracking, telemetry adapters. | Preview (mirrors `dspy` version) |
62
+ | `gepa` | GEPA optimizer core (Pareto engine, telemetry, reflective proposer). | Preview (mirrors `dspy` version) |
63
+ | `dspy-o11y` | Core observability APIs: `DSPy::Observability`, async span processor, observation types. | **Stable** (v1.0.0) |
64
+ | `dspy-o11y-langfuse` | Auto-configures DSPy observability to stream spans to Langfuse via OTLP. | **Stable** (v1.0.0) |
65
+
66
+ Set the matching `DSPY_WITH_*` environment variables (see `Gemfile`) to include or exclude each sibling gem when running Bundler locally (for example `DSPY_WITH_GEPA=1` or `DSPY_WITH_O11Y_LANGFUSE=1`).
67
+ ### Your First Reliable Predictor
68
+
69
+ ```ruby
70
+
71
+ # Configure DSPy globablly to use your fave LLM - you can override this on an instance levle.
72
+ DSPy.configure do |c|
73
+ c.lm = DSPy::LM.new('openai/gpt-4o-mini',
74
+ api_key: ENV['OPENAI_API_KEY'],
75
+ structured_outputs: true) # Enable OpenAI's native JSON mode
76
+ end
77
+
78
+ # Define a signature for sentiment classification - instead of writing a full prompt!
79
+ class Classify < DSPy::Signature
80
+ description "Classify sentiment of a given sentence." # sets the goal of the underlying prompt
81
+
82
+ class Sentiment < T::Enum
83
+ enums do
84
+ Positive = new('positive')
85
+ Negative = new('negative')
86
+ Neutral = new('neutral')
87
+ end
88
+ end
89
+
90
+ # Structured Inputs: makes sure you are sending only valid prompt inputs to your model
91
+ input do
92
+ const :sentence, String, description: 'The sentence to analyze'
93
+ end
94
+
95
+ # Structured Outputs: your predictor will validate the output of the model too.
96
+ output do
97
+ const :sentiment, Sentiment, description: 'The sentiment of the sentence'
98
+ const :confidence, Float, description: 'A number between 0.0 and 1.0'
99
+ end
100
+ end
101
+
102
+ # Wire it to the simplest prompting technique - a Predictn.
103
+ classify = DSPy::Predict.new(Classify)
104
+ # it may raise an error if you mess the inputs or your LLM messes the outputs.
105
+ result = classify.call(sentence: "This book was super fun to read!")
106
+
107
+ puts result.sentiment # => #<Sentiment::Positive>
108
+ puts result.confidence # => 0.85
109
+ ```
110
+
111
+ ### Access to 200+ Models Across 5 Providers
112
+
113
+ DSPy.rb provides unified access to major LLM providers with provider-specific optimizations:
114
+
115
+ ```ruby
116
+ # OpenAI (GPT-4, GPT-4o, GPT-4o-mini, GPT-5, etc.)
117
+ DSPy.configure do |c|
118
+ c.lm = DSPy::LM.new('openai/gpt-4o-mini',
119
+ api_key: ENV['OPENAI_API_KEY'],
120
+ structured_outputs: true) # Native JSON mode
121
+ end
122
+
123
+ # Google Gemini (Gemini 1.5 Pro, Flash, Gemini 2.0, etc.)
124
+ DSPy.configure do |c|
125
+ c.lm = DSPy::LM.new('gemini/gemini-2.5-flash',
126
+ api_key: ENV['GEMINI_API_KEY'],
127
+ structured_outputs: true) # Native structured outputs
128
+ end
129
+
130
+ # Anthropic Claude (Claude 3.5, Claude 4, etc.)
131
+ DSPy.configure do |c|
132
+ c.lm = DSPy::LM.new('anthropic/claude-sonnet-4-5-20250929',
133
+ api_key: ENV['ANTHROPIC_API_KEY'],
134
+ structured_outputs: true) # Tool-based extraction (default)
135
+ end
136
+
137
+ # Ollama - Run any local model (Llama, Mistral, Gemma, etc.)
138
+ DSPy.configure do |c|
139
+ c.lm = DSPy::LM.new('ollama/llama3.2') # Free, runs locally, no API key needed
140
+ end
141
+
142
+ # OpenRouter - Access to 200+ models from multiple providers
143
+ DSPy.configure do |c|
144
+ c.lm = DSPy::LM.new('openrouter/deepseek/deepseek-chat-v3.1:free',
145
+ api_key: ENV['OPENROUTER_API_KEY'])
146
+ end
147
+ ```
148
+
149
+ ## What You Get
150
+
151
+ **Developer Experience:**
152
+ - LLM provider support using official Ruby clients:
153
+ - [OpenAI Ruby](https://github.com/openai/openai-ruby) with vision model support
154
+ - [Anthropic Ruby SDK](https://github.com/anthropics/anthropic-sdk-ruby) with multimodal capabilities
155
+ - [Google Gemini API](https://ai.google.dev/) with native structured outputs
156
+ - [Ollama](https://ollama.com/) via OpenAI compatibility layer for local models
157
+ - **Multimodal Support** - Complete image analysis with DSPy::Image, type-safe bounding boxes, vision-capable models
158
+ - Runtime type checking with [Sorbet](https://sorbet.org/) including T::Enum and union types
159
+ - Type-safe tool definitions for ReAct agents
160
+ - Comprehensive instrumentation and observability
161
+
162
+ **Core Building Blocks:**
163
+ - **Signatures** - Define input/output schemas using Sorbet types with T::Enum and union type support
164
+ - **Predict** - LLM completion with structured data extraction and multimodal support
165
+ - **Chain of Thought** - Step-by-step reasoning for complex problems with automatic prompt optimization
166
+ - **ReAct** - Tool-using agents with type-safe tool definitions and error recovery
167
+ - **Module Composition** - Combine multiple LLM calls into production-ready workflows
168
+
169
+ **Optimization & Evaluation:**
170
+ - **Prompt Objects** - Manipulate prompts as first-class objects instead of strings
171
+ - **Typed Examples** - Type-safe training data with automatic validation
172
+ - **Evaluation Framework** - Advanced metrics beyond simple accuracy with error-resilient pipelines
173
+ - **MIPROv2 Optimization** - Advanced Bayesian optimization with Gaussian Processes, multiple optimization strategies, auto-config presets, and storage persistence
174
+
175
+ **Production Features:**
176
+ - **Reliable JSON Extraction** - Native structured outputs for OpenAI and Gemini, Anthropic tool-based extraction, and automatic strategy selection with fallback
177
+ - **Type-Safe Configuration** - Strategy enums with automatic provider optimization (Strict/Compatible modes)
178
+ - **Smart Retry Logic** - Progressive fallback with exponential backoff for handling transient failures
179
+ - **Zero-Config Langfuse Integration** - Set env vars and get automatic OpenTelemetry traces in Langfuse
180
+ - **Performance Caching** - Schema and capability caching for faster repeated operations
181
+ - **File-based Storage** - Optimization result persistence with versioning
182
+ - **Structured Logging** - JSON and key=value formats with span tracking
183
+
184
+ ## Recent Achievements
185
+
186
+ DSPy.rb has rapidly evolved from experimental to production-ready:
187
+
188
+ ### Foundation
189
+ - ✅ **JSON Parsing Reliability** - Native OpenAI structured outputs with adaptive retry logic and schema-aware fallbacks
190
+ - ✅ **Type-Safe Strategy Configuration** - Provider-optimized strategy selection and enum-backed optimizer presets
191
+ - ✅ **Core Module System** - Predict, ChainOfThought, ReAct with type safety (add `dspy-code_act` for Think-Code-Observe agents)
192
+ - ✅ **Production Observability** - OpenTelemetry, New Relic, and Langfuse integration
193
+ - ✅ **Advanced Optimization** - MIPROv2 with Bayesian optimization, Gaussian Processes, and multi-mode search
194
+
195
+ ### Recent Advances
196
+ - ✅ **MIPROv2 ADE Integrity (v0.29.1)** - Stratified train/val/test splits, honest precision accounting, and enum-driven `--auto` presets with integration coverage
197
+ - ✅ **Instruction Deduplication (v0.29.1)** - Candidate generation now filters repeated programs so optimization logs highlight unique strategies
198
+ - ✅ **GEPA Teleprompter (v0.29.0)** - Genetic-Pareto reflective prompt evolution with merge proposer scheduling, reflective mutation, and ADE demo parity
199
+ - ✅ **Optimizer Utilities Parity (v0.29.0)** - Bootstrap strategies, dataset summaries, and Layer 3 utilities unlock multi-predictor programs on Ruby
200
+ - ✅ **Observability Hardening (v0.29.0)** - OTLP exporter runs on a single-thread executor preventing frozen SSL contexts without blocking spans
201
+ - ✅ **Documentation Refresh (v0.29.x)** - New GEPA guide plus ADE optimization docs covering presets, stratified splits, and error-handling defaults
202
+
203
+ **Current Focus Areas:**
204
+
205
+ ### Production Readiness
206
+ - 🚧 **Production Patterns** - Real-world usage validation and performance optimization
207
+ - 🚧 **Ruby Ecosystem Integration** - Rails integration, Sidekiq compatibility, deployment patterns
208
+
209
+ ### Community & Adoption
210
+ - 🚧 **Community Examples** - Real-world applications and case studies
211
+ - 🚧 **Contributor Experience** - Making it easier to contribute and extend
212
+ - 🚧 **Performance Benchmarks** - Comparative analysis vs other frameworks
213
+
214
+ **v1.0 Philosophy:**
215
+ v1.0 will be released after extensive production battle-testing, not after checking off features.
216
+ The API is already stable - v1.0 represents confidence in production reliability backed by real-world validation.
217
+
218
+
219
+ ## Documentation
220
+
221
+ 📖 **[Complete Documentation Website](https://vicentereig.github.io/dspy.rb/)**
222
+
223
+ ### LLM-Friendly Documentation
224
+
225
+ For LLMs and AI assistants working with DSPy.rb:
226
+ - **[llms.txt](https://vicentereig.github.io/dspy.rb/llms.txt)** - Concise reference optimized for LLMs
227
+ - **[llms-full.txt](https://vicentereig.github.io/dspy.rb/llms-full.txt)** - Comprehensive API documentation
228
+
229
+ ### Getting Started
230
+ - **[Installation & Setup](docs/src/getting-started/installation.md)** - Detailed installation and configuration
231
+ - **[Quick Start Guide](docs/src/getting-started/quick-start.md)** - Your first DSPy programs
232
+ - **[Core Concepts](docs/src/getting-started/core-concepts.md)** - Understanding signatures, predictors, and modules
233
+
234
+ ### Prompt Engineering
235
+ - **[Signatures & Types](docs/src/core-concepts/signatures.md)** - Define typed interfaces for LLM operations
236
+ - **[Predictors](docs/src/core-concepts/predictors.md)** - Predict, ChainOfThought, ReAct, and more
237
+ - **[Modules & Pipelines](docs/src/core-concepts/modules.md)** - Compose complex multi-stage workflows
238
+ - **[Multimodal Support](docs/src/core-concepts/multimodal.md)** - Image analysis with vision-capable models
239
+ - **[Examples & Validation](docs/src/core-concepts/examples.md)** - Type-safe training data
240
+ - **[Rich Types](docs/src/advanced/complex-types.md)** - Sorbet type integration with automatic coercion for structs, enums, and arrays
241
+ - **[Composable Pipelines](docs/src/advanced/pipelines.md)** - Manual module composition patterns
242
+
243
+ ### Prompt Optimization
244
+ - **[Evaluation Framework](docs/src/optimization/evaluation.md)** - Advanced metrics beyond simple accuracy
245
+ - **[Prompt Optimization](docs/src/optimization/prompt-optimization.md)** - Manipulate prompts as objects
246
+ - **[MIPROv2 Optimizer](docs/src/optimization/miprov2.md)** - Advanced Bayesian optimization with Gaussian Processes
247
+ - **[GEPA Optimizer](docs/src/optimization/gepa.md)** *(beta)* - Reflective mutation with optional reflection LMs
248
+
249
+ ### Context Engineering
250
+ - **[Tools](docs/src/core-concepts/toolsets.md)** - Tool wieldint agents.
251
+ - **[Agentic Memory](docs/src/core-concepts/memory.md)** - Memory Tools & Agentic Loops
252
+ - **[RAG Patterns](docs/src/advanced/rag.md)** - Manual RAG implementation with external services
253
+
254
+ ### Production Features
255
+ - **[Observability](docs/src/production/observability.md)** - Zero-config Langfuse integration with a dedicated export worker that never blocks your LLMs
256
+ - **[Storage System](docs/src/production/storage.md)** - Persistence and optimization result storage
257
+ - **[Custom Metrics](docs/src/advanced/custom-metrics.md)** - Proc-based evaluation logic
258
+
259
+
260
+
261
+
262
+
263
+
264
+
265
+
266
+ ## License
267
+ This project is licensed under the MIT License.
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'thread'
5
+ require 'opentelemetry/sdk'
6
+ require 'opentelemetry/sdk/trace/export'
7
+
8
+ module DSPy
9
+ class Observability
10
+ # AsyncSpanProcessor provides non-blocking span export using concurrent-ruby.
11
+ # Spans are queued and exported on a dedicated single-thread executor to avoid blocking clients.
12
+ # Implements the same interface as OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor
13
+ class AsyncSpanProcessor
14
+ # Default configuration values
15
+ DEFAULT_QUEUE_SIZE = 1000
16
+ DEFAULT_EXPORT_INTERVAL = 60.0 # seconds
17
+ DEFAULT_EXPORT_BATCH_SIZE = 100
18
+ DEFAULT_SHUTDOWN_TIMEOUT = 10.0 # seconds
19
+ DEFAULT_MAX_RETRIES = 3
20
+
21
+ def initialize(
22
+ exporter,
23
+ queue_size: DEFAULT_QUEUE_SIZE,
24
+ export_interval: DEFAULT_EXPORT_INTERVAL,
25
+ export_batch_size: DEFAULT_EXPORT_BATCH_SIZE,
26
+ shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
27
+ max_retries: DEFAULT_MAX_RETRIES
28
+ )
29
+ @exporter = exporter
30
+ @queue_size = queue_size
31
+ @export_interval = export_interval
32
+ @export_batch_size = export_batch_size
33
+ @shutdown_timeout = shutdown_timeout
34
+ @max_retries = max_retries
35
+ @export_executor = Concurrent::SingleThreadExecutor.new
36
+
37
+ # Use thread-safe queue for cross-fiber communication
38
+ @queue = Thread::Queue.new
39
+ @shutdown_requested = false
40
+ @timer_thread = nil
41
+
42
+ start_export_task
43
+ end
44
+
45
+ def on_start(span, parent_context)
46
+ # Non-blocking - no operation needed on span start
47
+ end
48
+
49
+ def on_finish(span)
50
+ # Only process sampled spans to match BatchSpanProcessor behavior
51
+ return unless span.context.trace_flags.sampled?
52
+
53
+ # Non-blocking enqueue with overflow protection
54
+ # Note: on_finish is only called for already ended spans
55
+ begin
56
+ # Check queue size (non-blocking)
57
+ if @queue.size >= @queue_size
58
+ # Drop oldest span
59
+ begin
60
+ dropped_span = @queue.pop(true) # non-blocking pop
61
+ DSPy.log('observability.span_dropped',
62
+ reason: 'queue_full',
63
+ queue_size: @queue_size)
64
+ rescue ThreadError
65
+ # Queue was empty, continue
66
+ end
67
+ end
68
+
69
+ @queue.push(span)
70
+
71
+ # Log span queuing activity
72
+ DSPy.log('observability.span_queued', queue_size: @queue.size)
73
+
74
+ # Trigger immediate export if batch size reached
75
+ trigger_export_if_batch_full
76
+ rescue => e
77
+ DSPy.log('observability.enqueue_error', error: e.message)
78
+ end
79
+ end
80
+
81
+ def shutdown(timeout: nil)
82
+ timeout ||= @shutdown_timeout
83
+ @shutdown_requested = true
84
+
85
+ begin
86
+ # Export any remaining spans
87
+ result = export_remaining_spans(timeout: timeout, export_all: true)
88
+
89
+ future = Concurrent::Promises.future_on(@export_executor) do
90
+ @exporter.shutdown(timeout: timeout)
91
+ end
92
+ future.value!(timeout)
93
+
94
+ result
95
+ rescue => e
96
+ DSPy.log('observability.shutdown_error', error: e.message, class: e.class.name)
97
+ OpenTelemetry::SDK::Trace::Export::FAILURE
98
+ ensure
99
+ begin
100
+ @timer_thread&.join(timeout)
101
+ @timer_thread&.kill if @timer_thread&.alive?
102
+ rescue StandardError
103
+ # ignore timer shutdown issues
104
+ end
105
+ @export_executor.shutdown
106
+ unless @export_executor.wait_for_termination(timeout)
107
+ @export_executor.kill
108
+ end
109
+ end
110
+ end
111
+
112
+ def force_flush(timeout: nil)
113
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if @queue.empty?
114
+
115
+ export_remaining_spans(timeout: timeout, export_all: true)
116
+ end
117
+
118
+ private
119
+
120
+ def start_export_task
121
+ return if @export_interval <= 0 # Disable timer for testing
122
+ return if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true' # Skip in tests
123
+
124
+ @timer_thread = Thread.new do
125
+ loop do
126
+ break if @shutdown_requested
127
+
128
+ sleep(@export_interval)
129
+ break if @shutdown_requested
130
+ next if @queue.empty?
131
+
132
+ schedule_async_export(export_all: true)
133
+ end
134
+ rescue => e
135
+ DSPy.log('observability.export_task_error', error: e.message, class: e.class.name)
136
+ end
137
+ end
138
+
139
+ def trigger_export_if_batch_full
140
+ return if @queue.size < @export_batch_size
141
+ return if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true' # Skip in tests
142
+ schedule_async_export(export_all: false)
143
+ end
144
+
145
+ def export_remaining_spans(timeout: nil, export_all: true)
146
+ return OpenTelemetry::SDK::Trace::Export::SUCCESS if @queue.empty?
147
+
148
+ future = Concurrent::Promises.future_on(@export_executor) do
149
+ export_queued_spans_internal(export_all: export_all)
150
+ end
151
+
152
+ future.value!(timeout || @shutdown_timeout)
153
+ rescue => e
154
+ DSPy.log('observability.export_error', error: e.message, class: e.class.name)
155
+ OpenTelemetry::SDK::Trace::Export::FAILURE
156
+ end
157
+
158
+ def schedule_async_export(export_all: false)
159
+ return if @shutdown_requested
160
+
161
+ @export_executor.post do
162
+ export_queued_spans_internal(export_all: export_all)
163
+ rescue => e
164
+ DSPy.log('observability.batch_export_error', error: e.message, class: e.class.name)
165
+ end
166
+ end
167
+
168
+ def export_queued_spans
169
+ export_queued_spans_internal(export_all: false)
170
+ end
171
+
172
+ def export_queued_spans_internal(export_all: false)
173
+ result = OpenTelemetry::SDK::Trace::Export::SUCCESS
174
+
175
+ loop do
176
+ spans = dequeue_spans(export_all ? @queue_size : @export_batch_size)
177
+ break if spans.empty?
178
+
179
+ result = export_spans_with_retry(spans)
180
+ break if result == OpenTelemetry::SDK::Trace::Export::FAILURE
181
+
182
+ break unless export_all || @queue.size >= @export_batch_size
183
+ end
184
+
185
+ result
186
+ end
187
+
188
+ def dequeue_spans(limit)
189
+ spans = []
190
+
191
+ limit.times do
192
+ begin
193
+ spans << @queue.pop(true) # non-blocking pop
194
+ rescue ThreadError
195
+ break
196
+ end
197
+ end
198
+
199
+ spans
200
+ end
201
+
202
+ def export_spans_with_retry(spans)
203
+ retries = 0
204
+
205
+ # Convert spans to SpanData objects (required by OTLP exporter)
206
+ span_data_batch = spans.map(&:to_span_data)
207
+
208
+ # Log export attempt
209
+ DSPy.log('observability.export_attempt',
210
+ spans_count: span_data_batch.size,
211
+ batch_size: span_data_batch.size)
212
+
213
+ loop do
214
+ result = @exporter.export(span_data_batch, timeout: @shutdown_timeout)
215
+
216
+ case result
217
+ when OpenTelemetry::SDK::Trace::Export::SUCCESS
218
+ DSPy.log('observability.export_success',
219
+ spans_count: span_data_batch.size,
220
+ export_result: 'SUCCESS')
221
+ return result
222
+ when OpenTelemetry::SDK::Trace::Export::FAILURE
223
+ retries += 1
224
+ if retries <= @max_retries
225
+ backoff_seconds = 0.1 * (2 ** retries)
226
+ DSPy.log('observability.export_retry',
227
+ attempt: retries,
228
+ spans_count: span_data_batch.size,
229
+ backoff_seconds: backoff_seconds)
230
+ # Exponential backoff
231
+ sleep(backoff_seconds)
232
+ next
233
+ else
234
+ DSPy.log('observability.export_failed',
235
+ spans_count: span_data_batch.size,
236
+ retries: retries)
237
+ return result
238
+ end
239
+ else
240
+ return result
241
+ end
242
+ end
243
+ rescue => e
244
+ DSPy.log('observability.export_error', error: e.message, class: e.class.name)
245
+ OpenTelemetry::SDK::Trace::Export::FAILURE
246
+ end
247
+
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module O11y
5
+ module Langfuse
6
+ VERSION = '1.0.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'dspy/o11y'
7
+ require_relative 'langfuse/version'
8
+
9
+ module DSPy
10
+ class Observability
11
+ module Adapters
12
+ module Langfuse
13
+ module_function
14
+
15
+ def register!
16
+ DSPy::Observability.register_configurator(:langfuse) do |obs|
17
+ configure(obs)
18
+ end
19
+ end
20
+
21
+ def configure(obs)
22
+ return obs.disable!(reason: 'Explicitly disabled via DSPY_DISABLE_OBSERVABILITY') if ENV['DSPY_DISABLE_OBSERVABILITY'] == 'true'
23
+
24
+ public_key = ENV['LANGFUSE_PUBLIC_KEY']
25
+ secret_key = ENV['LANGFUSE_SECRET_KEY']
26
+
27
+ if test_environment? && !(public_key && secret_key)
28
+ return obs.disable!(reason: 'Test environment detected - OTLP disabled')
29
+ end
30
+
31
+ return false unless public_key && secret_key
32
+
33
+ require_opentelemetry!
34
+ patch_frozen_ssl_context_for_otlp!
35
+
36
+ endpoint = langfuse_endpoint
37
+ auth_string = Base64.strict_encode64("#{public_key}:#{secret_key}")
38
+
39
+ OpenTelemetry::SDK.configure do |config|
40
+ config.service_name = 'dspy-ruby'
41
+ config.service_version = DSPy::VERSION
42
+
43
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
44
+ endpoint: endpoint,
45
+ headers: {
46
+ 'Authorization' => "Basic #{auth_string}",
47
+ 'Content-Type' => 'application/x-protobuf'
48
+ },
49
+ compression: 'gzip'
50
+ )
51
+
52
+ async_config = {
53
+ queue_size: (ENV['DSPY_TELEMETRY_QUEUE_SIZE'] || DSPy::Observability::AsyncSpanProcessor::DEFAULT_QUEUE_SIZE).to_i,
54
+ export_interval: (ENV['DSPY_TELEMETRY_EXPORT_INTERVAL'] || DSPy::Observability::AsyncSpanProcessor::DEFAULT_EXPORT_INTERVAL).to_f,
55
+ export_batch_size: (ENV['DSPY_TELEMETRY_BATCH_SIZE'] || DSPy::Observability::AsyncSpanProcessor::DEFAULT_EXPORT_BATCH_SIZE).to_i,
56
+ shutdown_timeout: (ENV['DSPY_TELEMETRY_SHUTDOWN_TIMEOUT'] || DSPy::Observability::AsyncSpanProcessor::DEFAULT_SHUTDOWN_TIMEOUT).to_f
57
+ }
58
+
59
+ config.add_span_processor(
60
+ DSPy::Observability::AsyncSpanProcessor.new(exporter, **async_config)
61
+ )
62
+
63
+ config.resource = OpenTelemetry::SDK::Resources::Resource.create({
64
+ 'service.name' => 'dspy-ruby',
65
+ 'service.version' => DSPy::VERSION,
66
+ 'telemetry.sdk.name' => 'opentelemetry',
67
+ 'telemetry.sdk.language' => 'ruby'
68
+ })
69
+ end
70
+
71
+ tracer = OpenTelemetry.tracer_provider.tracer('dspy', DSPy::VERSION)
72
+ obs.enable!(tracer: tracer, endpoint: endpoint)
73
+ true
74
+ rescue LoadError
75
+ obs.disable!(reason: 'OpenTelemetry gems not available')
76
+ false
77
+ rescue StandardError => e
78
+ DSPy.log('observability.error', error: e.message, adapter: 'langfuse', class: e.class.name)
79
+ obs.disable!
80
+ false
81
+ end
82
+
83
+ def test_environment?
84
+ ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test' || defined?(RSpec)
85
+ end
86
+ private_class_method :test_environment?
87
+
88
+ def require_opentelemetry!
89
+ DSPy::Observability.require_dependency('opentelemetry/sdk')
90
+ DSPy::Observability.require_dependency('opentelemetry/exporter/otlp')
91
+ end
92
+ private_class_method :require_opentelemetry!
93
+
94
+ def langfuse_endpoint
95
+ host = ENV['LANGFUSE_HOST'] || 'https://cloud.langfuse.com'
96
+ "#{host}/api/public/otel/v1/traces"
97
+ end
98
+ private_class_method :langfuse_endpoint
99
+
100
+ def patch_frozen_ssl_context_for_otlp!
101
+ return unless defined?(OpenTelemetry::Exporter::OTLP::Exporter)
102
+
103
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter
104
+ keep_alive_timeout = exporter.const_get(:KEEP_ALIVE_TIMEOUT)
105
+ return if exporter.instance_variable_defined?(:@_dspy_ssl_patch_applied)
106
+
107
+ exporter.class_eval do
108
+ define_method(:http_connection) do |uri, ssl_verify_mode, certificate_file, client_certificate_file, client_key_file|
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ use_ssl = uri.scheme == 'https'
111
+ http.use_ssl = use_ssl
112
+
113
+ if use_ssl && http.respond_to?(:ssl_context) && http.ssl_context&.frozen?
114
+ http.instance_variable_set(:@ssl_context, OpenSSL::SSL::SSLContext.new)
115
+ end
116
+
117
+ http.verify_mode = ssl_verify_mode
118
+ http.ca_file = certificate_file unless certificate_file.nil?
119
+ http.cert = OpenSSL::X509::Certificate.new(File.read(client_certificate_file)) unless client_certificate_file.nil?
120
+ http.key = OpenSSL::PKey::RSA.new(File.read(client_key_file)) unless client_key_file.nil?
121
+ http.keep_alive_timeout = keep_alive_timeout
122
+ http
123
+ end
124
+ end
125
+
126
+ exporter.instance_variable_set(:@_dspy_ssl_patch_applied, true)
127
+ end
128
+ private_class_method :patch_frozen_ssl_context_for_otlp!
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ DSPy::Observability::Adapters::Langfuse.register!
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ class Observability
5
+ class << self
6
+ attr_reader :tracer, :endpoint
7
+
8
+ def register_configurator(name, &block)
9
+ configurators[name.to_sym] = block
10
+ end
11
+
12
+ def configure!(adapter: nil)
13
+ reset!
14
+
15
+ blocks = if adapter
16
+ block = configurators[adapter.to_sym]
17
+ block ? [block] : []
18
+ else
19
+ configurators.values
20
+ end
21
+
22
+ return false if blocks.empty?
23
+
24
+ blocks.each do |config|
25
+ begin
26
+ result = config.call(self)
27
+ return true if result || enabled?
28
+ rescue StandardError => e
29
+ DSPy.log('observability.error', error: e.message, adapter: adapter)
30
+ end
31
+ end
32
+
33
+ false
34
+ end
35
+
36
+ def enabled?
37
+ @enabled == true
38
+ end
39
+
40
+ def enable!(tracer:, endpoint: nil)
41
+ @tracer = tracer
42
+ @endpoint = endpoint
43
+ @enabled = true
44
+ end
45
+
46
+ def disable!(reason: nil)
47
+ @enabled = false
48
+ @tracer = nil
49
+ @endpoint = nil
50
+ DSPy.log('observability.disabled', reason: reason) if reason
51
+ end
52
+
53
+ def start_span(operation_name, attributes = {})
54
+ return nil unless enabled? && tracer
55
+
56
+ string_attributes = attributes.transform_keys(&:to_s)
57
+ .reject { |_, v| v.nil? }
58
+ string_attributes['operation.name'] = operation_name
59
+
60
+ tracer.start_span(
61
+ operation_name,
62
+ kind: :internal,
63
+ attributes: string_attributes
64
+ )
65
+ rescue StandardError => e
66
+ DSPy.log('observability.span_error', error: e.message, operation: operation_name)
67
+ nil
68
+ end
69
+
70
+ def finish_span(span)
71
+ return unless span
72
+
73
+ span.finish
74
+ rescue StandardError => e
75
+ DSPy.log('observability.span_finish_error', error: e.message)
76
+ end
77
+
78
+ def flush!
79
+ return unless enabled?
80
+ return unless defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:tracer_provider)
81
+
82
+ OpenTelemetry.tracer_provider.force_flush
83
+ rescue StandardError => e
84
+ DSPy.log('observability.flush_error', error: e.message)
85
+ end
86
+
87
+ def reset!
88
+ if defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:tracer_provider) && (provider = OpenTelemetry.tracer_provider)
89
+ begin
90
+ provider.shutdown(timeout: 1.0) if provider.respond_to?(:shutdown)
91
+ rescue StandardError => e
92
+ DSPy.log('observability.shutdown_error', error: e.message)
93
+ end
94
+ end
95
+
96
+ @enabled = false
97
+ @tracer = nil
98
+ @endpoint = nil
99
+ end
100
+
101
+ def configurators
102
+ @configurators ||= {}
103
+ end
104
+
105
+ def require_dependency(lib)
106
+ require lib
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Langfuse observation types as a T::Enum for type safety
7
+ # Maps to the official Langfuse observation types: https://langfuse.com/docs/observability/features/observation-types
8
+ class ObservationType < T::Enum
9
+ enums do
10
+ # LLM generation calls - used for direct model inference
11
+ Generation = new('generation')
12
+
13
+ # Agent operations - decision-making processes using tools/LLM guidance
14
+ Agent = new('agent')
15
+
16
+ # External tool calls (APIs, functions, etc.)
17
+ Tool = new('tool')
18
+
19
+ # Chains linking different application steps/components
20
+ Chain = new('chain')
21
+
22
+ # Data retrieval operations (vector stores, databases, memory search)
23
+ Retriever = new('retriever')
24
+
25
+ # Embedding generation calls
26
+ Embedding = new('embedding')
27
+
28
+ # Functions that assess quality/relevance of outputs
29
+ Evaluator = new('evaluator')
30
+
31
+ # Generic spans for durations of work units
32
+ Span = new('span')
33
+
34
+ # Discrete events/moments in time
35
+ Event = new('event')
36
+ end
37
+
38
+ # Get the appropriate observation type for a DSPy module class
39
+ sig { params(module_class: T.untyped).returns(ObservationType) }
40
+ def self.for_module_class(module_class)
41
+ case module_class.name
42
+ when /ReAct/, /CodeAct/
43
+ Agent
44
+ when /ChainOfThought/
45
+ Chain
46
+ when /Evaluator/
47
+ Evaluator
48
+ else
49
+ Span
50
+ end
51
+ end
52
+
53
+ # Returns the langfuse attribute key and value as an array
54
+ sig { returns([String, String]) }
55
+ def langfuse_attribute
56
+ ['langfuse.observation.type', serialize]
57
+ end
58
+
59
+ # Returns a hash with the langfuse attribute for easy merging
60
+ sig { returns(T::Hash[String, String]) }
61
+ def langfuse_attributes
62
+ { 'langfuse.observation.type' => serialize }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ module O11y
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
data/lib/dspy/o11y.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'o11y/version'
4
+ require_relative 'o11y/observability'
5
+ require_relative 'o11y/observation_type'
6
+ require_relative 'o11y/async_span_processor'
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dspy-o11y
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vicente Reig Rincón de Arellano
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-10-25 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dspy
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.29.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.29.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: opentelemetry-sdk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.8'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.8'
54
+ description: Provides DSPy::Observability, AsyncSpanProcessor, and ObservationType
55
+ so instrumentation can be enabled independently from the main DSPy gem.
56
+ email:
57
+ - hey@vicente.services
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/dspy/o11y.rb
65
+ - lib/dspy/o11y/async_span_processor.rb
66
+ - lib/dspy/o11y/langfuse.rb
67
+ - lib/dspy/o11y/langfuse/version.rb
68
+ - lib/dspy/o11y/observability.rb
69
+ - lib/dspy/o11y/observation_type.rb
70
+ - lib/dspy/o11y/version.rb
71
+ homepage: https://github.com/vicentereig/dspy.rb
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ github_repo: git@github.com:vicentereig/dspy.rb
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.3.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.5
91
+ specification_version: 4
92
+ summary: Observability core (spans, context hooks, and telemetry helpers) for DSPy.rb.
93
+ test_files: []