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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd9018b27dbb87f1518a96570bedf1bf7b09702b18816f072f9764075cb6adf8
4
- data.tar.gz: d69c6d0194c193fbb8567beb42d98042fa2e36fe24e855a0429753d7440efc63
3
+ metadata.gz: 5dbe9ea720616e2913af73fd43f00815f5ba0f4abc1003a28362800d25df651f
4
+ data.tar.gz: b9f37149e9d81794dced53aa493e76507ebb223470bc88c4036bb5a06ac20ecc
5
5
  SHA512:
6
- metadata.gz: 156b32b2b1c09ef127183b8899dda9ba58bd837895b0baeb36074bc507b1ebed4a959847a9ca71b7dfaac93bcd8d0f35afe7a5aa9677ff8e9959c0a1132d4e56
7
- data.tar.gz: '08a7208efb8a94a5d8a798fa4a3d490575342ca807629a0ba43e577ad7fb9c0f15c0c0476cc65067c1540f127332f9a79ebe07b2047a3c922f721f6bb56f1f17'
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.1.1...HEAD
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 `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
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
 
@@ -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:
@@ -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
- @conversation_history << { role: "user", content: user_query }
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
- # Step 2: Execute the plan
169
- results = execute_plan(plan)
163
+ plan = plan_execution(user_query)
164
+ results = execute_plan(plan)
165
+ response = synthesize_response(user_query, results)
170
166
 
171
- # Step 3: Synthesize final response
172
- response = synthesize_response(user_query, results)
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
@@ -148,22 +148,17 @@ def trace_llm_chain(user_question)
148
148
  end
149
149
  end
150
150
 
151
- # Example 4: Using Traceable module for LLM service class
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
- # In real code: response = @client.chat.completions.create(...)
163
- response = simulate_openai_response(messages, @model)
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
- response[:choices].first[:message][:content]
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
- # Simulate embedding call
182
- tokens_used = (text.length / 4.0).ceil
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
- Array.new(1536) { rand(-1.0..1.0) }
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. Using Traceable module:"
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
- # Step 1: Embed the question
428
- question_embedding = embed_query(question)
424
+ Langsmith.trace("rag_chain", run_type: "chain", inputs: { question: question }) do
425
+ question_embedding = embed_query(question)
429
426
 
430
- # Step 2: Retrieve relevant context
431
- context = retrieve_context(question_embedding)
427
+ context = retrieve_context(question_embedding)
432
428
 
433
- # Step 3: Generate answer
434
- generate_answer(question, context)
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
- response = TracedOpenAI.embed(input: text)
442
- response.dig("data", 0, "embedding")
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
- # Simulate vector search - in real app, query your vector DB
448
- Langsmith.current_run&.add_metadata(
449
- index: "knowledge_base",
450
- top_k: 3
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
- messages = [
459
- {
460
- role: "system",
461
- content: "Answer the question based on the following context:\n\n#{context.join("\n\n")}"
462
- },
463
- { role: "user", content: question }
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
- response = TracedOpenAI.chat(messages: messages, model: "gpt-4o-mini")
467
- response.dig("choices", 0, "message", "content")
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 a Mutex to protect flush_pending from concurrent access
12
- # - Uses Concurrent::Array for thread-safe pending queues
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
- @pending_creates = Concurrent::Array.new
29
- @pending_updates = Concurrent::Array.new
30
- @flush_task = nil
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
- flush_pending
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
- # Use to_h for creates (full data), to_update_h for updates (minimal PATCH payload)
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
- t.report_on_exception = false
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
- @pending_creates << build_pending_entry(entry)
170
+ add_pending(:creates, entry)
135
171
  when UPDATE
136
- @pending_updates << build_pending_entry(entry)
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
- def build_pending_entry(entry)
145
- { data: entry[:run_data], tenant_id: entry[:tenant_id] }
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
- @pending_creates.size + @pending_updates.size
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
- creates = extract_all(@pending_creates)
182
- updates = extract_all(@pending_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
- send_batches(creates, updates)
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
- def extract_all(array)
191
- result = []
192
- result << array.shift until array.empty?
193
- result
194
- rescue ThreadError
195
- result
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.batch_ingest_raw(**params)
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
- log_error("Unexpected error sending #{param_key}: #{e.message}")
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)
@@ -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 batch_ingest_raw(post_runs: [], patch_runs: [], tenant_id: nil)
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Langsmith
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/langsmith.rb CHANGED
@@ -8,7 +8,6 @@ require_relative "langsmith/context"
8
8
  require_relative "langsmith/client"
9
9
  require_relative "langsmith/batch_processor"
10
10
  require_relative "langsmith/run_tree"
11
- require_relative "langsmith/traceable"
12
11
 
13
12
  module Langsmith
14
13
  class << self
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.1.1
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:
@@ -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