langsmith-sdk 0.1.1 → 0.2.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 +19 -2
- data/README.md +1 -39
- data/examples/LLM_TRACING.md +0 -58
- data/examples/complex_agent.rb +8 -14
- data/examples/llm_tracing.rb +10 -18
- data/examples/openai_integration.rb +24 -30
- data/lib/langsmith/batch_processor.rb +148 -29
- data/lib/langsmith/client.rb +1 -24
- data/lib/langsmith/configuration.rb +4 -0
- data/lib/langsmith/version.rb +1 -1
- data/lib/langsmith.rb +0 -1
- metadata +1 -2
- data/lib/langsmith/traceable.rb +0 -120
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5dbe9ea720616e2913af73fd43f00815f5ba0f4abc1003a28362800d25df651f
|
|
4
|
+
data.tar.gz: b9f37149e9d81794dced53aa493e76507ebb223470bc88c4036bb5a06ac20ecc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3d6e11333b5324f986d5fbe5a75caeb44ae5a8008a330eec1af39d1f8800268a00129861373f8fa514a14a5cb4d7d486a2d49cb82e4484df7b25bdadf513538
|
|
7
|
+
data.tar.gz: fdc400de8ebc5fb807f2b64762fdb496bc290294b2592a4a41289847de254ad81c29b039ced662f1141f991c55157c6261ef9b3589f5d61c795ee3d0a3631fc5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2025-12-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `max_pending_entries` configuration option to limit buffer size and prevent unbounded memory growth
|
|
15
|
+
- Configurable via `LANGSMITH_MAX_PENDING_ENTRIES` environment variable
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Improved BatchProcessor thread safety with dedicated mutexes for pending arrays and flush operations
|
|
20
|
+
- Better error logging in BatchProcessor with stack traces for debugging
|
|
21
|
+
- Run data is now serialized on the calling thread to ensure correct state capture
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- **BREAKING**: Removed `Langsmith::Traceable` module - use `Langsmith.trace` block-based API instead
|
|
26
|
+
|
|
10
27
|
## [0.1.1] - 2025-12-21
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -20,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
20
37
|
|
|
21
38
|
- Initial release of the LangSmith Ruby SDK
|
|
22
39
|
- Block-based tracing with `Langsmith.trace`
|
|
23
|
-
- Method decoration with `Langsmith::Traceable` module
|
|
24
40
|
- Automatic parent-child trace linking for nested traces
|
|
25
41
|
- Thread-safe batch processing with background worker
|
|
26
42
|
- Thread-local context for proper isolation in concurrent environments
|
|
@@ -42,7 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
42
58
|
- `prompt` - Prompt template rendering
|
|
43
59
|
- `parser` - Output parsing operations
|
|
44
60
|
|
|
45
|
-
[Unreleased]: https://github.com/felipekb/langsmith-ruby-sdk/compare/v0.
|
|
61
|
+
[Unreleased]: https://github.com/felipekb/langsmith-ruby-sdk/compare/v0.2.0...HEAD
|
|
62
|
+
[0.2.0]: https://github.com/felipekb/langsmith-ruby-sdk/compare/v0.1.1...v0.2.0
|
|
46
63
|
[0.1.1]: https://github.com/felipekb/langsmith-ruby-sdk/compare/v0.1.0...v0.1.1
|
|
47
64
|
[0.1.0]: https://github.com/felipekb/langsmith-ruby-sdk/releases/tag/v0.1.0
|
|
48
65
|
|
data/README.md
CHANGED
|
@@ -79,26 +79,6 @@ Langsmith.trace("parent_chain", run_type: "chain") do
|
|
|
79
79
|
end
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
### Method Decoration with Traceable
|
|
83
|
-
|
|
84
|
-
```ruby
|
|
85
|
-
class MyService
|
|
86
|
-
include Langsmith::Traceable
|
|
87
|
-
|
|
88
|
-
traceable run_type: "chain"
|
|
89
|
-
def process(input)
|
|
90
|
-
# This method is automatically traced
|
|
91
|
-
transform(input)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
traceable run_type: "llm", name: "openai_call"
|
|
95
|
-
def call_llm(prompt)
|
|
96
|
-
# Traced with custom name
|
|
97
|
-
client.chat(prompt)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
```
|
|
101
|
-
|
|
102
82
|
## Run Types
|
|
103
83
|
|
|
104
84
|
Supported run types:
|
|
@@ -157,24 +137,6 @@ Langsmith.trace("operation", tenant_id: "tenant-456") do
|
|
|
157
137
|
end
|
|
158
138
|
```
|
|
159
139
|
|
|
160
|
-
### With Traceable Module
|
|
161
|
-
|
|
162
|
-
```ruby
|
|
163
|
-
class MultiTenantService
|
|
164
|
-
include Langsmith::Traceable
|
|
165
|
-
|
|
166
|
-
traceable run_type: "chain", tenant_id: "tenant-123"
|
|
167
|
-
def process_for_tenant_123(data)
|
|
168
|
-
# Always traced to tenant-123
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
traceable run_type: "chain", tenant_id: "tenant-456"
|
|
172
|
-
def process_for_tenant_456(data)
|
|
173
|
-
# Always traced to tenant-456
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
```
|
|
177
|
-
|
|
178
140
|
The SDK automatically batches traces by tenant ID, so traces for different tenants are sent in separate API requests with the appropriate `X-Tenant-Id` header.
|
|
179
141
|
|
|
180
142
|
## Token Usage Tracking
|
|
@@ -216,7 +178,7 @@ See [`examples/LLM_TRACING.md`](examples/LLM_TRACING.md) for comprehensive examp
|
|
|
216
178
|
|
|
217
179
|
## Development
|
|
218
180
|
|
|
219
|
-
After checking out the repo, run `
|
|
181
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
|
220
182
|
|
|
221
183
|
## License
|
|
222
184
|
|
data/examples/LLM_TRACING.md
CHANGED
|
@@ -8,7 +8,6 @@ This guide shows how to trace LLM calls with the LangSmith Ruby SDK, including t
|
|
|
8
8
|
- [Adding Metadata](#adding-metadata)
|
|
9
9
|
- [Streaming LLM Calls](#streaming-llm-calls)
|
|
10
10
|
- [Multi-Step Chains](#multi-step-chains)
|
|
11
|
-
- [Using the Traceable Module](#using-the-traceable-module)
|
|
12
11
|
- [OpenAI Integration](#openai-integration)
|
|
13
12
|
- [Anthropic Integration](#anthropic-integration)
|
|
14
13
|
- [Error Handling](#error-handling)
|
|
@@ -176,63 +175,6 @@ end
|
|
|
176
175
|
|
|
177
176
|
---
|
|
178
177
|
|
|
179
|
-
## Using the Traceable Module
|
|
180
|
-
|
|
181
|
-
Decorate methods for automatic tracing:
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
class LLMService
|
|
185
|
-
include Langsmith::Traceable
|
|
186
|
-
|
|
187
|
-
def initialize(model: "gpt-4")
|
|
188
|
-
@model = model
|
|
189
|
-
@client = OpenAI::Client.new
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
traceable run_type: "llm", name: "llm_service.chat"
|
|
193
|
-
def chat(messages, temperature: 0.7)
|
|
194
|
-
response = @client.chat(
|
|
195
|
-
parameters: {
|
|
196
|
-
model: @model,
|
|
197
|
-
messages: messages,
|
|
198
|
-
temperature: temperature
|
|
199
|
-
}
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
# Access current run to set token usage
|
|
203
|
-
if (run = Langsmith.current_run)
|
|
204
|
-
run.set_token_usage(
|
|
205
|
-
prompt_tokens: response["usage"]["prompt_tokens"],
|
|
206
|
-
completion_tokens: response["usage"]["completion_tokens"]
|
|
207
|
-
)
|
|
208
|
-
run.add_metadata(model: @model, temperature: temperature)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
response.dig("choices", 0, "message", "content")
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
traceable run_type: "llm", name: "llm_service.embed"
|
|
215
|
-
def embed(text)
|
|
216
|
-
response = @client.embeddings(
|
|
217
|
-
parameters: { model: "text-embedding-3-small", input: text }
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
Langsmith.current_run&.set_token_usage(
|
|
221
|
-
prompt_tokens: response["usage"]["prompt_tokens"],
|
|
222
|
-
completion_tokens: 0
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
response.dig("data", 0, "embedding")
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Usage
|
|
230
|
-
service = LLMService.new(model: "gpt-4")
|
|
231
|
-
response = service.chat([{ role: "user", content: "Hello!" }])
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
---
|
|
235
|
-
|
|
236
178
|
## OpenAI Integration
|
|
237
179
|
|
|
238
180
|
Complete wrapper for the ruby-openai gem:
|
data/examples/complex_agent.rb
CHANGED
|
@@ -152,27 +152,21 @@ end
|
|
|
152
152
|
# =============================================================================
|
|
153
153
|
|
|
154
154
|
class ResearchAgent
|
|
155
|
-
include Langsmith::Traceable
|
|
156
|
-
|
|
157
155
|
def initialize
|
|
158
156
|
@conversation_history = []
|
|
159
157
|
end
|
|
160
158
|
|
|
161
|
-
traceable run_type: "chain", name: "research_agent.run"
|
|
162
159
|
def run(user_query)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# Step 1: Analyze the query and plan
|
|
166
|
-
plan = plan_execution(user_query)
|
|
160
|
+
Langsmith.trace("research_agent.run", run_type: "chain", inputs: { query: user_query }) do
|
|
161
|
+
@conversation_history << { role: "user", content: user_query }
|
|
167
162
|
|
|
168
|
-
|
|
169
|
-
|
|
163
|
+
plan = plan_execution(user_query)
|
|
164
|
+
results = execute_plan(plan)
|
|
165
|
+
response = synthesize_response(user_query, results)
|
|
170
166
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
@conversation_history << { role: "assistant", content: response }
|
|
175
|
-
response
|
|
167
|
+
@conversation_history << { role: "assistant", content: response }
|
|
168
|
+
response
|
|
169
|
+
end
|
|
176
170
|
end
|
|
177
171
|
|
|
178
172
|
private
|
data/examples/llm_tracing.rb
CHANGED
|
@@ -148,22 +148,17 @@ def trace_llm_chain(user_question)
|
|
|
148
148
|
end
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
# Example 4:
|
|
151
|
+
# Example 4: Class-based tracing
|
|
152
152
|
class LLMService
|
|
153
|
-
include Langsmith::Traceable
|
|
154
|
-
|
|
155
153
|
def initialize(model: "gpt-4", temperature: 0.7)
|
|
156
154
|
@model = model
|
|
157
155
|
@temperature = temperature
|
|
158
156
|
end
|
|
159
157
|
|
|
160
|
-
traceable run_type: "llm", name: "llm_service.chat"
|
|
161
158
|
def chat(messages)
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
Langsmith.trace("llm_service.chat", run_type: "llm", inputs: { messages: messages.length }) do |run|
|
|
160
|
+
response = simulate_openai_response(messages, @model)
|
|
164
161
|
|
|
165
|
-
# Access current run to set model and token usage (Python SDK pattern)
|
|
166
|
-
if (run = Langsmith.current_run)
|
|
167
162
|
run.set_model(model: @model, provider: "openai")
|
|
168
163
|
run.set_token_usage(
|
|
169
164
|
input_tokens: response[:usage][:prompt_tokens],
|
|
@@ -171,24 +166,21 @@ class LLMService
|
|
|
171
166
|
total_tokens: response[:usage][:total_tokens]
|
|
172
167
|
)
|
|
173
168
|
run.add_metadata(temperature: @temperature)
|
|
174
|
-
end
|
|
175
169
|
|
|
176
|
-
|
|
170
|
+
response[:choices].first[:message][:content]
|
|
171
|
+
end
|
|
177
172
|
end
|
|
178
173
|
|
|
179
|
-
traceable run_type: "llm", name: "llm_service.embed"
|
|
180
174
|
def embed(text)
|
|
181
|
-
|
|
182
|
-
|
|
175
|
+
Langsmith.trace("llm_service.embed", run_type: "llm", inputs: { text_preview: text[0..30] }) do |run|
|
|
176
|
+
tokens_used = (text.length / 4.0).ceil
|
|
183
177
|
|
|
184
|
-
if (run = Langsmith.current_run)
|
|
185
178
|
run.set_model(model: "text-embedding-3-small", provider: "openai")
|
|
186
|
-
# Embeddings only have input tokens, no output tokens
|
|
187
179
|
run.set_token_usage(input_tokens: tokens_used)
|
|
188
180
|
run.add_metadata(dimensions: 1536)
|
|
189
|
-
end
|
|
190
181
|
|
|
191
|
-
|
|
182
|
+
Array.new(1536) { rand(-1.0..1.0) }
|
|
183
|
+
end
|
|
192
184
|
end
|
|
193
185
|
end
|
|
194
186
|
|
|
@@ -284,7 +276,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
284
276
|
result = trace_llm_chain("How do I trace LLM calls?")
|
|
285
277
|
puts " Response: #{result}"
|
|
286
278
|
|
|
287
|
-
puts "\n4.
|
|
279
|
+
puts "\n4. Class-based tracing:"
|
|
288
280
|
service = LLMService.new(model: "gpt-4", temperature: 0.5)
|
|
289
281
|
result = service.chat([{ role: "user", content: "Hello!" }])
|
|
290
282
|
puts " Chat response: #{result}"
|
|
@@ -416,55 +416,49 @@ end
|
|
|
416
416
|
|
|
417
417
|
# Example: RAG chain with OpenAI
|
|
418
418
|
class RAGChain
|
|
419
|
-
include Langsmith::Traceable
|
|
420
|
-
|
|
421
419
|
def initialize(knowledge_base:)
|
|
422
420
|
@knowledge_base = knowledge_base
|
|
423
421
|
end
|
|
424
422
|
|
|
425
|
-
traceable run_type: "chain", name: "rag_chain"
|
|
426
423
|
def answer(question)
|
|
427
|
-
|
|
428
|
-
|
|
424
|
+
Langsmith.trace("rag_chain", run_type: "chain", inputs: { question: question }) do
|
|
425
|
+
question_embedding = embed_query(question)
|
|
429
426
|
|
|
430
|
-
|
|
431
|
-
context = retrieve_context(question_embedding)
|
|
427
|
+
context = retrieve_context(question_embedding)
|
|
432
428
|
|
|
433
|
-
|
|
434
|
-
|
|
429
|
+
generate_answer(question, context)
|
|
430
|
+
end
|
|
435
431
|
end
|
|
436
432
|
|
|
437
433
|
private
|
|
438
434
|
|
|
439
|
-
traceable run_type: "llm", name: "embed_query"
|
|
440
435
|
def embed_query(text)
|
|
441
|
-
|
|
442
|
-
|
|
436
|
+
Langsmith.trace("embed_query", run_type: "llm", inputs: { text: text[0..50] }) do
|
|
437
|
+
response = TracedOpenAI.embed(input: text)
|
|
438
|
+
response.dig("data", 0, "embedding")
|
|
439
|
+
end
|
|
443
440
|
end
|
|
444
441
|
|
|
445
|
-
traceable run_type: "retriever", name: "retrieve_context"
|
|
446
442
|
def retrieve_context(embedding)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
)
|
|
452
|
-
|
|
453
|
-
@knowledge_base.first(3)
|
|
443
|
+
Langsmith.trace("retrieve_context", run_type: "retriever", inputs: { top_k: 3 }) do |run|
|
|
444
|
+
run.add_metadata(index: "knowledge_base", top_k: 3)
|
|
445
|
+
@knowledge_base.first(3)
|
|
446
|
+
end
|
|
454
447
|
end
|
|
455
448
|
|
|
456
|
-
traceable run_type: "llm", name: "generate_answer"
|
|
457
449
|
def generate_answer(question, context)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
450
|
+
Langsmith.trace("generate_answer", run_type: "llm", inputs: { question: question }) do
|
|
451
|
+
messages = [
|
|
452
|
+
{
|
|
453
|
+
role: "system",
|
|
454
|
+
content: "Answer the question based on the following context:\n\n#{context.join("\n\n")}"
|
|
455
|
+
},
|
|
456
|
+
{ role: "user", content: question }
|
|
457
|
+
]
|
|
465
458
|
|
|
466
|
-
|
|
467
|
-
|
|
459
|
+
response = TracedOpenAI.chat(messages: messages, model: "gpt-4o-mini")
|
|
460
|
+
response.dig("choices", 0, "message", "content")
|
|
461
|
+
end
|
|
468
462
|
end
|
|
469
463
|
end
|
|
470
464
|
|
|
@@ -8,27 +8,35 @@ module Langsmith
|
|
|
8
8
|
#
|
|
9
9
|
# Thread Safety:
|
|
10
10
|
# - Uses AtomicBoolean for atomic start/shutdown
|
|
11
|
-
# - Uses
|
|
12
|
-
# - Uses
|
|
11
|
+
# - Uses @pending_mutex to protect all pending array access (add + extract)
|
|
12
|
+
# - Uses @flush_mutex to ensure only one flush operation runs at a time
|
|
13
|
+
# - HTTP calls happen outside locks to avoid blocking the worker
|
|
13
14
|
class BatchProcessor
|
|
14
15
|
# Entry types for the queue
|
|
15
16
|
CREATE = :create
|
|
16
17
|
UPDATE = :update
|
|
17
18
|
SHUTDOWN = :shutdown
|
|
18
19
|
|
|
19
|
-
def initialize(client: nil, batch_size: nil, flush_interval: nil)
|
|
20
|
+
def initialize(client: nil, batch_size: nil, flush_interval: nil, max_pending_entries: nil)
|
|
20
21
|
config = Langsmith.configuration
|
|
21
22
|
@client = client || Client.new
|
|
22
23
|
@batch_size = batch_size || config.batch_size
|
|
23
24
|
@flush_interval = flush_interval || config.flush_interval
|
|
25
|
+
@max_pending_entries = max_pending_entries || config.max_pending_entries
|
|
24
26
|
|
|
25
27
|
@queue = Queue.new
|
|
26
28
|
@running = Concurrent::AtomicBoolean.new(false)
|
|
27
29
|
@worker_thread = Concurrent::AtomicReference.new(nil)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@
|
|
30
|
+
|
|
31
|
+
# Use regular arrays protected by mutex (simpler than Concurrent::Array)
|
|
32
|
+
@pending_creates = []
|
|
33
|
+
@pending_updates = []
|
|
34
|
+
@pending_mutex = Mutex.new
|
|
35
|
+
|
|
36
|
+
# Separate mutex for flush operations to prevent concurrent flushes
|
|
31
37
|
@flush_mutex = Mutex.new
|
|
38
|
+
|
|
39
|
+
@flush_task = nil
|
|
32
40
|
@shutdown_hook_registered = false
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -66,7 +74,14 @@ module Langsmith
|
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
def flush
|
|
69
|
-
|
|
77
|
+
ensure_started
|
|
78
|
+
|
|
79
|
+
# Drain anything currently in the queue into pending, then flush.
|
|
80
|
+
# Run a second drain pass to catch items enqueued while we were flushing.
|
|
81
|
+
2.times do
|
|
82
|
+
drain_queue_non_blocking
|
|
83
|
+
flush_pending
|
|
84
|
+
end
|
|
70
85
|
end
|
|
71
86
|
|
|
72
87
|
def running?
|
|
@@ -82,15 +97,20 @@ module Langsmith
|
|
|
82
97
|
end
|
|
83
98
|
|
|
84
99
|
ensure_started
|
|
85
|
-
|
|
100
|
+
|
|
101
|
+
# Snapshot run data on the calling thread to capture state at enqueue time.
|
|
102
|
+
# This ensures CREATE captures initial state and UPDATE captures final state.
|
|
103
|
+
# Trade-off: serialization happens on the hot path, but semantics are correct.
|
|
86
104
|
run_data = type == CREATE ? run.to_h : run.to_update_h
|
|
87
105
|
@queue << { type: type, run_data: run_data, tenant_id: run.tenant_id }
|
|
106
|
+
trim_buffer_if_needed
|
|
88
107
|
end
|
|
89
108
|
|
|
90
109
|
def create_worker_thread
|
|
91
110
|
Thread.new { worker_loop }.tap do |t|
|
|
92
111
|
t.abort_on_exception = false
|
|
93
|
-
|
|
112
|
+
# Enable reporting so we at least see errors in logs
|
|
113
|
+
t.report_on_exception = true
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
116
|
|
|
@@ -124,16 +144,32 @@ module Langsmith
|
|
|
124
144
|
|
|
125
145
|
flush_if_batch_full
|
|
126
146
|
rescue StandardError => e
|
|
127
|
-
log_error("Batch processor error: #{e.message}")
|
|
147
|
+
log_error("Batch processor error: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
128
148
|
end
|
|
129
149
|
end
|
|
130
150
|
|
|
151
|
+
# Non-blocking drain of the queue into pending arrays.
|
|
152
|
+
# Returns true if any entries were drained.
|
|
153
|
+
def drain_queue_non_blocking
|
|
154
|
+
drained = false
|
|
155
|
+
|
|
156
|
+
loop do
|
|
157
|
+
entry = pop_queue_non_blocking
|
|
158
|
+
break unless entry
|
|
159
|
+
|
|
160
|
+
process_entry(entry) unless entry[:type] == SHUTDOWN
|
|
161
|
+
drained = true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
drained
|
|
165
|
+
end
|
|
166
|
+
|
|
131
167
|
def process_entry(entry)
|
|
132
168
|
case entry[:type]
|
|
133
169
|
when CREATE
|
|
134
|
-
|
|
170
|
+
add_pending(:creates, entry)
|
|
135
171
|
when UPDATE
|
|
136
|
-
|
|
172
|
+
add_pending(:updates, entry)
|
|
137
173
|
when SHUTDOWN
|
|
138
174
|
drain_queue
|
|
139
175
|
flush_pending
|
|
@@ -141,8 +177,17 @@ module Langsmith
|
|
|
141
177
|
end
|
|
142
178
|
end
|
|
143
179
|
|
|
144
|
-
|
|
145
|
-
|
|
180
|
+
# Thread-safe add to pending arrays
|
|
181
|
+
def add_pending(type, entry)
|
|
182
|
+
pending_entry = { data: entry[:run_data], tenant_id: entry[:tenant_id] }
|
|
183
|
+
@pending_mutex.synchronize do
|
|
184
|
+
case type
|
|
185
|
+
when :creates
|
|
186
|
+
@pending_creates << pending_entry
|
|
187
|
+
when :updates
|
|
188
|
+
@pending_updates << pending_entry
|
|
189
|
+
end
|
|
190
|
+
end
|
|
146
191
|
end
|
|
147
192
|
|
|
148
193
|
def drain_queue
|
|
@@ -172,35 +217,49 @@ module Langsmith
|
|
|
172
217
|
pending_count.positive?
|
|
173
218
|
end
|
|
174
219
|
|
|
220
|
+
# Approximate count - doesn't need to be perfectly synchronized
|
|
221
|
+
# since it's just used for heuristic batch-full checks
|
|
175
222
|
def pending_count
|
|
176
|
-
@
|
|
223
|
+
@pending_mutex.synchronize do
|
|
224
|
+
@pending_creates.size + @pending_updates.size
|
|
225
|
+
end
|
|
177
226
|
end
|
|
178
227
|
|
|
179
228
|
def flush_pending
|
|
229
|
+
# Only one flush at a time
|
|
180
230
|
@flush_mutex.synchronize do
|
|
181
|
-
|
|
182
|
-
updates =
|
|
231
|
+
# Atomically extract all pending items
|
|
232
|
+
creates, updates = extract_pending
|
|
183
233
|
|
|
184
234
|
return if creates.empty? && updates.empty?
|
|
185
235
|
|
|
186
|
-
|
|
236
|
+
# HTTP calls happen outside @pending_mutex to avoid blocking the worker
|
|
237
|
+
failed_creates, failed_updates = send_batches(creates, updates)
|
|
238
|
+
|
|
239
|
+
requeue_failed(failed_creates, failed_updates)
|
|
187
240
|
end
|
|
188
241
|
end
|
|
189
242
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
243
|
+
# Atomically extract and clear pending arrays
|
|
244
|
+
# Returns [creates, updates] arrays
|
|
245
|
+
def extract_pending
|
|
246
|
+
@pending_mutex.synchronize do
|
|
247
|
+
creates = @pending_creates.dup
|
|
248
|
+
updates = @pending_updates.dup
|
|
249
|
+
@pending_creates.clear
|
|
250
|
+
@pending_updates.clear
|
|
251
|
+
[creates, updates]
|
|
252
|
+
end
|
|
196
253
|
end
|
|
197
254
|
|
|
198
255
|
def send_batches(creates, updates)
|
|
199
256
|
by_tenant = group_by_tenant(creates, updates)
|
|
200
257
|
|
|
201
258
|
# Send POSTs first, then PATCHes (LangSmith needs runs created before updating)
|
|
202
|
-
send_batch_type(by_tenant, :creates, :post_runs)
|
|
203
|
-
send_batch_type(by_tenant, :updates, :patch_runs)
|
|
259
|
+
failed_creates = send_batch_type(by_tenant, :creates, :post_runs)
|
|
260
|
+
failed_updates = send_batch_type(by_tenant, :updates, :patch_runs)
|
|
261
|
+
|
|
262
|
+
[failed_creates, failed_updates]
|
|
204
263
|
end
|
|
205
264
|
|
|
206
265
|
def group_by_tenant(creates, updates)
|
|
@@ -211,23 +270,83 @@ module Langsmith
|
|
|
211
270
|
end
|
|
212
271
|
|
|
213
272
|
def send_batch_type(by_tenant, type_key, param_key)
|
|
273
|
+
failed = []
|
|
274
|
+
|
|
214
275
|
by_tenant[type_key].each do |tenant_id, entries|
|
|
215
276
|
runs = entries.map { |e| e[:data] }
|
|
216
277
|
next if runs.empty?
|
|
217
278
|
|
|
218
|
-
send_to_api(tenant_id, param_key, runs)
|
|
279
|
+
success = send_to_api(tenant_id, param_key, runs)
|
|
280
|
+
failed.concat(entries) unless success
|
|
219
281
|
end
|
|
282
|
+
|
|
283
|
+
failed
|
|
220
284
|
end
|
|
221
285
|
|
|
222
286
|
def send_to_api(tenant_id, param_key, runs)
|
|
223
287
|
params = { post_runs: [], patch_runs: [], tenant_id: tenant_id }
|
|
224
288
|
params[param_key] = runs
|
|
225
289
|
|
|
226
|
-
@client.
|
|
290
|
+
@client.batch_ingest(**params)
|
|
291
|
+
true
|
|
227
292
|
rescue Client::APIError => e
|
|
228
293
|
log_error("Failed to send #{param_key} for tenant #{tenant_id}: #{e.message}", force: true)
|
|
294
|
+
false
|
|
229
295
|
rescue StandardError => e
|
|
230
|
-
|
|
296
|
+
# Force logging so unexpected failures don't silently drop traces
|
|
297
|
+
log_error("Unexpected error sending #{param_key}: #{e.message}", force: true)
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def requeue_failed(failed_creates, failed_updates)
|
|
302
|
+
return if failed_creates.empty? && failed_updates.empty?
|
|
303
|
+
|
|
304
|
+
@pending_mutex.synchronize do
|
|
305
|
+
@pending_creates.concat(failed_creates)
|
|
306
|
+
@pending_updates.concat(failed_updates)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
trim_buffer_if_needed
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def trim_buffer_if_needed
|
|
313
|
+
return unless @max_pending_entries
|
|
314
|
+
|
|
315
|
+
while current_buffer_size > @max_pending_entries
|
|
316
|
+
drop_one_entry
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def current_buffer_size
|
|
321
|
+
queue_size = @queue.size
|
|
322
|
+
pending_size = @pending_mutex.synchronize { @pending_creates.size + @pending_updates.size }
|
|
323
|
+
queue_size + pending_size
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def drop_one_entry
|
|
327
|
+
entry = pop_queue_non_blocking
|
|
328
|
+
entry ||= pop_pending_non_blocking
|
|
329
|
+
log_dropped(entry) if entry
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def pop_queue_non_blocking
|
|
333
|
+
@queue.pop(true)
|
|
334
|
+
rescue ThreadError
|
|
335
|
+
nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def pop_pending_non_blocking
|
|
339
|
+
@pending_mutex.synchronize do
|
|
340
|
+
return @pending_creates.shift unless @pending_creates.empty?
|
|
341
|
+
return @pending_updates.shift unless @pending_updates.empty?
|
|
342
|
+
end
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def log_dropped(entry)
|
|
347
|
+
return unless ENV["LANGSMITH_DEBUG"]
|
|
348
|
+
|
|
349
|
+
log_error("Dropped run entry due to max_pending_entries cap (type: #{entry[:type]}, tenant: #{entry[:tenant_id]})")
|
|
231
350
|
end
|
|
232
351
|
|
|
233
352
|
def log_error(message, force: false)
|
data/lib/langsmith/client.rb
CHANGED
|
@@ -66,29 +66,6 @@ module Langsmith
|
|
|
66
66
|
patch("/runs/#{run.id}", run.to_h, tenant_id: run.tenant_id)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# Batch create/update runs.
|
|
70
|
-
# All runs in a batch should have the same tenant_id for optimal performance.
|
|
71
|
-
#
|
|
72
|
-
# @param post_runs [Array<Run>] runs to create
|
|
73
|
-
# @param patch_runs [Array<Run>] runs to update
|
|
74
|
-
# @param tenant_id [String, nil] tenant ID (inferred from runs if not provided)
|
|
75
|
-
# @return [Hash, nil] API response
|
|
76
|
-
# @raise [APIError] if the request fails
|
|
77
|
-
def batch_ingest(post_runs: [], patch_runs: [], tenant_id: nil)
|
|
78
|
-
return if post_runs.empty? && patch_runs.empty?
|
|
79
|
-
|
|
80
|
-
payload = {}
|
|
81
|
-
payload[:post] = post_runs.map(&:to_h) unless post_runs.empty?
|
|
82
|
-
payload[:patch] = patch_runs.map(&:to_h) unless patch_runs.empty?
|
|
83
|
-
|
|
84
|
-
# Use tenant_id from first run if not explicitly provided
|
|
85
|
-
effective_tenant_id = tenant_id ||
|
|
86
|
-
post_runs.first&.tenant_id ||
|
|
87
|
-
patch_runs.first&.tenant_id
|
|
88
|
-
|
|
89
|
-
post("/runs/batch", payload, tenant_id: effective_tenant_id)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
69
|
# Batch create/update runs using pre-serialized hashes.
|
|
93
70
|
# Used by BatchProcessor which snapshots run data at enqueue time.
|
|
94
71
|
#
|
|
@@ -97,7 +74,7 @@ module Langsmith
|
|
|
97
74
|
# @param tenant_id [String, nil] tenant ID for the request
|
|
98
75
|
# @return [Hash, nil] API response
|
|
99
76
|
# @raise [APIError] if the request fails
|
|
100
|
-
def
|
|
77
|
+
def batch_ingest(post_runs: [], patch_runs: [], tenant_id: nil)
|
|
101
78
|
return if post_runs.empty? && patch_runs.empty?
|
|
102
79
|
|
|
103
80
|
payload = {}
|
|
@@ -42,6 +42,9 @@ module Langsmith
|
|
|
42
42
|
# @return [String, nil] Tenant ID for multi-tenant scenarios
|
|
43
43
|
attr_accessor :tenant_id
|
|
44
44
|
|
|
45
|
+
# @return [Integer, nil] Maximum buffered run entries (queue + pending); nil means unlimited
|
|
46
|
+
attr_accessor :max_pending_entries
|
|
47
|
+
|
|
45
48
|
def initialize
|
|
46
49
|
@api_key = ENV.fetch("LANGSMITH_API_KEY", nil)
|
|
47
50
|
@endpoint = ENV.fetch("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
|
@@ -52,6 +55,7 @@ module Langsmith
|
|
|
52
55
|
@timeout = ENV.fetch("LANGSMITH_TIMEOUT", 10).to_i
|
|
53
56
|
@max_retries = ENV.fetch("LANGSMITH_MAX_RETRIES", 3).to_i
|
|
54
57
|
@tenant_id = ENV.fetch("LANGSMITH_TENANT_ID", nil)
|
|
58
|
+
@max_pending_entries = ENV.fetch("LANGSMITH_MAX_PENDING_ENTRIES", nil)&.to_i
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# Returns whether tracing is enabled in configuration.
|
data/lib/langsmith/version.rb
CHANGED
data/lib/langsmith.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: langsmith-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Felipe Cabezudo
|
|
@@ -101,7 +101,6 @@ files:
|
|
|
101
101
|
- lib/langsmith/railtie.rb
|
|
102
102
|
- lib/langsmith/run.rb
|
|
103
103
|
- lib/langsmith/run_tree.rb
|
|
104
|
-
- lib/langsmith/traceable.rb
|
|
105
104
|
- lib/langsmith/version.rb
|
|
106
105
|
homepage: https://github.com/felipekb/langsmith-ruby-sdk
|
|
107
106
|
licenses:
|
data/lib/langsmith/traceable.rb
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Langsmith
|
|
4
|
-
# Module that provides method decoration for automatic tracing.
|
|
5
|
-
# Include this module in your class and use the `traceable` class method
|
|
6
|
-
# to mark methods for tracing.
|
|
7
|
-
#
|
|
8
|
-
# @example
|
|
9
|
-
# class MyService
|
|
10
|
-
# include Langsmith::Traceable
|
|
11
|
-
#
|
|
12
|
-
# traceable run_type: "llm"
|
|
13
|
-
# def call_llm(prompt)
|
|
14
|
-
# # automatically traced
|
|
15
|
-
# end
|
|
16
|
-
#
|
|
17
|
-
# traceable run_type: "tool", name: "search"
|
|
18
|
-
# def search(query)
|
|
19
|
-
# # traced with custom name
|
|
20
|
-
# end
|
|
21
|
-
#
|
|
22
|
-
# traceable run_type: "chain", tenant_id: "tenant-123"
|
|
23
|
-
# def process_for_tenant(data)
|
|
24
|
-
# # traced to specific tenant
|
|
25
|
-
# end
|
|
26
|
-
# end
|
|
27
|
-
module Traceable
|
|
28
|
-
def self.included(base)
|
|
29
|
-
base.extend(ClassMethods)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
module ClassMethods
|
|
33
|
-
# Marks the next defined method as traceable
|
|
34
|
-
def traceable(run_type: "chain", name: nil, metadata: nil, tags: nil, tenant_id: nil)
|
|
35
|
-
@pending_traceable_options = {
|
|
36
|
-
run_type: run_type,
|
|
37
|
-
name: name,
|
|
38
|
-
metadata: metadata,
|
|
39
|
-
tags: tags,
|
|
40
|
-
tenant_id: tenant_id
|
|
41
|
-
}
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def method_added(method_name)
|
|
45
|
-
super
|
|
46
|
-
|
|
47
|
-
return unless @pending_traceable_options
|
|
48
|
-
|
|
49
|
-
options = @pending_traceable_options
|
|
50
|
-
@pending_traceable_options = nil
|
|
51
|
-
|
|
52
|
-
# Don't wrap private/protected methods that start with underscore
|
|
53
|
-
return if method_name.to_s.start_with?("_langsmith_")
|
|
54
|
-
|
|
55
|
-
wrap_method(method_name, options)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def wrap_method(method_name, options)
|
|
61
|
-
original_method = instance_method(method_name)
|
|
62
|
-
trace_name = options[:name] || "#{name}##{method_name}"
|
|
63
|
-
|
|
64
|
-
# Remove original method to avoid "method redefined" warning
|
|
65
|
-
remove_method(method_name)
|
|
66
|
-
|
|
67
|
-
define_method(method_name) do |*args, **kwargs, &block|
|
|
68
|
-
Langsmith.trace(
|
|
69
|
-
trace_name,
|
|
70
|
-
run_type: options[:run_type],
|
|
71
|
-
inputs: build_trace_inputs(args, kwargs, original_method),
|
|
72
|
-
metadata: options[:metadata],
|
|
73
|
-
tags: options[:tags],
|
|
74
|
-
tenant_id: options[:tenant_id]
|
|
75
|
-
) do |_run|
|
|
76
|
-
if kwargs.empty?
|
|
77
|
-
original_method.bind(self).call(*args, &block)
|
|
78
|
-
else
|
|
79
|
-
original_method.bind(self).call(*args, **kwargs, &block)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
private
|
|
87
|
-
|
|
88
|
-
def build_trace_inputs(args, kwargs, method)
|
|
89
|
-
params = method.parameters
|
|
90
|
-
inputs = {}
|
|
91
|
-
|
|
92
|
-
# Map positional arguments
|
|
93
|
-
args.each_with_index do |arg, index|
|
|
94
|
-
param = params[index]
|
|
95
|
-
param_name = param ? param[1] : "arg#{index}"
|
|
96
|
-
inputs[param_name] = serialize_input(arg)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Map keyword arguments
|
|
100
|
-
kwargs.each do |key, value|
|
|
101
|
-
inputs[key] = serialize_input(value)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
inputs
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def serialize_input(value)
|
|
108
|
-
case value
|
|
109
|
-
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
110
|
-
value
|
|
111
|
-
when Array
|
|
112
|
-
value.map { |v| serialize_input(v) }
|
|
113
|
-
when Hash
|
|
114
|
-
value.transform_values { |v| serialize_input(v) }
|
|
115
|
-
else
|
|
116
|
-
value.to_s
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|