langfuse-ruby 0.1.7 → 0.2.1

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: 98dc87bcfe8834aa485a810f1d955216a0c17e74a6b058f42f76539350b5c542
4
- data.tar.gz: b826b87fc097c83637969f81a0aa0ff93e520c63a73519c78bd84cee3258af16
3
+ metadata.gz: 45de0f0dc3915b41c725f9099843bd6fd1e29d9a532a11a13bb65ca744a753fa
4
+ data.tar.gz: d04b31ebc438237d2462fec8e3c5e407f27548eb40c25164a7dc939d39942183
5
5
  SHA512:
6
- metadata.gz: 80b9e696b8157772731a519712087f19e751caf72009a87995c5684793a9fba1d507ab38eaedccd31ce5fac9280634d07ce7646454a7854383312891a076c525
7
- data.tar.gz: a05b595c3cc2e437511dbafa44df1331b5d64802f2008a51797419cb1eba9691ec7edef68435f55a972dff4b699b449f57603ef1161a7e73127d22a7c346a799
6
+ metadata.gz: 7677e27533af07f139e14dc6d189d16c3d00a75a625d5058e332221b89d107e76206fadc9d1fd4175346eaab43cd11322f724aca87d61acffc1f267ec651d301
7
+ data.tar.gz: 2bb6e47d16ff17e62200f2da93e539549edaf329419bb352f6081d7983e813c30d03cf61b775f98f643fc7b199921def0e0944b8aaf717daa80a18de8bb8067e
@@ -7,15 +7,30 @@ on:
7
7
  branches: [ master ]
8
8
 
9
9
  jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v6
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: '4.0'
20
+ bundler-cache: true
21
+
22
+ - name: Run RuboCop
23
+ run: bundle exec rubocop
24
+
10
25
  test:
11
26
  runs-on: ubuntu-latest
12
27
 
13
28
  strategy:
14
29
  matrix:
15
- ruby-version: ['3.1', '3.2', '3.3', '3.4']
30
+ ruby-version: ['3.1', '3.2', '3.3', '3.4', '4.0']
16
31
 
17
32
  steps:
18
- - uses: actions/checkout@v4
33
+ - uses: actions/checkout@v6
19
34
 
20
35
  - name: Set up Ruby ${{ matrix.ruby-version }}
21
36
  uses: ruby/setup-ruby@v1
@@ -29,25 +44,21 @@ jobs:
29
44
  - name: Run offline tests
30
45
  run: bundle exec ruby scripts/test_offline.rb
31
46
 
32
- - name: Run RuboCop
33
- run: bundle exec rubocop
34
- continue-on-error: true
35
-
36
47
  build:
37
48
  runs-on: ubuntu-latest
38
- needs: test
49
+ needs: [lint, test]
39
50
 
40
51
  steps:
41
- - uses: actions/checkout@v4
52
+ - uses: actions/checkout@v6
42
53
 
43
54
  - name: Set up Ruby
44
55
  uses: ruby/setup-ruby@v1
45
56
  with:
46
- ruby-version: '3.4'
57
+ ruby-version: '4.0'
47
58
  bundler-cache: true
48
59
 
49
60
  - name: Build gem
50
61
  run: gem build langfuse-ruby.gemspec
51
62
 
52
63
  - name: Verify gem can be installed
53
- run: gem install langfuse-ruby-*.gem
64
+ run: gem install langfuse-ruby-*.gem
@@ -13,12 +13,12 @@ jobs:
13
13
  contents: write
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v6
17
17
 
18
18
  - name: Set up Ruby
19
19
  uses: ruby/setup-ruby@v1
20
20
  with:
21
- ruby-version: '3.4'
21
+ ruby-version: '4.0'
22
22
  bundler-cache: true
23
23
 
24
24
  - name: Run tests
data/.mise.toml ADDED
@@ -0,0 +1,8 @@
1
+ # mise project toolchain config
2
+ # https://mise.jdx.dev/configuration.html
3
+ #
4
+ # This project defaults to the latest stable Ruby release.
5
+ # Run `mise install` after cloning to install it, then `bundle install`.
6
+
7
+ [tools]
8
+ ruby = "latest"
data/.rubocop.yml CHANGED
@@ -47,7 +47,7 @@ Metrics/CyclomaticComplexity:
47
47
  Max: 20
48
48
 
49
49
  Metrics/PerceivedComplexity:
50
- Max: 20
50
+ Max: 25
51
51
 
52
52
  Metrics/ParameterLists:
53
53
  Enabled: false
@@ -63,4 +63,4 @@ Lint/UnusedMethodArgument:
63
63
  - 'lib/langfuse/evaluation.rb'
64
64
 
65
65
  Metrics/ClassLength:
66
- Max: 600
66
+ Max: 850
data/CHANGELOG.md CHANGED
@@ -7,17 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-17
11
+
12
+ ### Documentation
13
+ - README and examples now lead with **Langfuse v4** (`ingestion_mode: :otel`): OTLP ingestion, observations-first tracing, `usage_details` / `cost_details`, typed observations, and the Cloud cutoff of non-score `/api/public/ingestion` traffic on 16 November 2026
14
+ - Added [docs/V4.md](docs/V4.md) (attribute mapping, scores, cutover checklist) and `examples/v4_otel_tracing.rb`
15
+
16
+ ### Fixed
17
+ - **`Langfuse.trace` executed the block twice**: an exception raised inside the block was caught by the method-level `rescue` and the block was re-run with a `NullTrace` — duplicating LLM calls and their cost. Only trace creation degrades to `NullTrace` now; exceptions from the block propagate untouched
18
+ - **Typed API errors collapsed into `APIError`**: `AuthenticationError` / `RateLimitError` / `ValidationError` raised by `handle_response` were re-wrapped by the generic `rescue` in `#request`, so callers could not rescue them selectively
19
+ - **`Langfuse.configure` values for `timeout` / `retries` were ignored**: the `Client#initialize` defaults (30 / 3) shadowed the configured values
20
+ - **OTel mode dropped token usage**: only `promptTokens` / `completionTokens` were mapped. All legacy shapes (`promptTokens`, `inputTokens`, `input`) now map to `gen_ai.usage.*` and are normalized into `langfuse.observation.usage_details`, which is what Langfuse v4 uses for cost. Usage carrying a non-token `unit` is skipped instead of being reported as tokens
21
+ - **OTel mode produced duplicate observations**: the `*-create` and `*-update` events of one observation were exported as two spans sharing a span id, which the append-only v4 data model stores twice. They are collapsed into one span carrying the final state
22
+ - **OTel mode stringified structured attributes**: hashes and arrays (for example `model_parameters[:tools]`) were sent through Ruby's `inspect`; they are JSON-encoded now
23
+ - **OTel export leaked raw Faraday errors**: failures on the OTLP connection now surface as `TimeoutError` / `NetworkError` / `APIError`, matching the ingestion API path
24
+ - **`shutdown` killed the flush thread mid-send**: events already drained from the queue were lost. The thread is now signalled to stop and joined (5 s grace period, kill only as a fallback)
25
+ - **Flush thread was not fork-safe**: after `fork` (e.g. Puma workers) the child inherited a dead thread and the parent's queued events. Each process now recreates its own flush thread and drops inherited events instead of sending them twice
26
+ - **Permanently failing batches were re-queued forever**: a 4xx (validation/auth) failure re-queued the same events on every flush, blocking the queue. They are now dropped with a warning; transient failures (5xx, network) are still re-queued
27
+ - **Event queue races**: enqueue, the `trace-update` → `trace-create` merge, and the flush drain ran without a shared lock, so a concurrent flush could interleave with a merge. They are serialized by a queue mutex now
28
+ - **Prompt compilation re-expanded placeholders coming from variable values**: variables were substituted one after another, so a value containing `{{other_var}}` was expanded by a later round (user input could inject template syntax, and the result depended on hash order). All variables are now substituted in a single pass, and a value containing a placeholder stays literal
29
+ - **`Span#generation` and `Generation#generation` dropped `usage_details`, `cost_details` and `prompt`**: the parameters were missing from both signatures, so a generation created under a span or another generation silently lost its v4 cost data and prompt link (only `Trace#generation` forwarded them)
30
+ - **Retries hit non-retryable failures**: `#request` retried every error, so a 401 or a 422 was sent four times before failing. Only timeouts, network errors, 429 and 5xx are retried now
31
+ - **Retries ignored `Retry-After`**: a rate-limited request was retried on a fixed 1 s / 2 s / 3 s schedule regardless of the server's instruction, and all clients retried in lockstep because the delay carried no jitter
32
+ - **`Langfuse.get_prompt` retried twice over**: its own retry loop wrapped the HTTP layer's retries, so one call could issue up to nine requests (and a missing prompt was fetched three times before returning `nil`). Retries now happen in one place
33
+ - **An unknown `ingestion_mode` silently behaved like `:legacy`**: a typo such as `LANGFUSE_INGESTION_MODE=otlp` looked like a working v4 setup. The value is now normalized (`"OTEL"` → `:otel`) and validated, with a warning when it is not recognized
34
+ - **A flushed `trace-update` sent unprocessed `to_dict` output**: when the matching `trace-create` was already gone from the queue, the reconstructed body used symbol/snake_case keys and skipped environment injection and the `mask` callable, so the API could reject the event and PII could leave unredacted. The reconstruction now re-runs the same prepare/env/mask path as every other enqueue
35
+
10
36
  ### Added
11
- - **Simplified API**: New class-level convenience methods for easier usage
12
- - `Langfuse.trace(name, **options, &block)` - Block-based tracing with automatic flush
13
- - `Langfuse.get_prompt(name, variables:, retries:)` - Get and compile prompts with retry support
14
- - `Langfuse.client` - Thread-safe singleton client access
15
- - `Langfuse.flush` / `Langfuse.shutdown` / `Langfuse.reset!` - Client lifecycle management
16
- - **Graceful Degradation**: Null object pattern for fault tolerance
17
- - `NullTrace`, `NullGeneration`, `NullSpan`, `NullEvent` - No-op objects when Langfuse is unavailable
18
- - **Retry Support**: `get_prompt` now supports configurable retries with exponential backoff (default: 2 retries)
19
- - New example file `examples/simplified_usage.rb` demonstrating the simplified API
20
- - Comprehensive test coverage for convenience methods (28 new tests)
37
+ - OTLP payloads are chunked to the 3.5 MB batch limit (previously only the ingestion API path was chunked)
38
+ - OTLP `partialSuccess` responses (HTTP 200 with rejected spans) are logged as warnings instead of silently losing data
39
+ - `max_queue_size` config (constructor / `Langfuse.configure` / `LANGFUSE_MAX_QUEUE_SIZE`, default 10,000): the event queue is bounded; when full, the oldest events are dropped with a rate-limited warning instead of growing memory without bound
40
+ - `get_prompt` resilience: when a refetch fails, the expired cache entry is served with a warning instead of raising (a Langfuse outage no longer breaks prompt resolution for previously fetched prompts). The prompt cache is now bounded (200 entries) and measures TTLs on the monotonic clock, so wall-clock jumps cannot extend or shorten entry lifetimes
41
+ - `Client#inspect` redacts the secret key, keeping it out of logs and console output
42
+ - `http_adapter` config (constructor / `Langfuse.configure`): selects the Faraday adapter, so a connection-pooling adapter such as `:net_http_persistent` can be used to keep the TLS connection alive between flushes. Defaults to Faraday's default adapter; an adapter whose gem is missing logs a warning and falls back instead of raising
43
+
44
+ ### Changed
45
+ - Retries are now applied only to transient failures (`TimeoutError`, `NetworkError`, `RateLimitError`, 5xx `APIError`), honor `Retry-After` (seconds or HTTP date, capped at 10 s), and otherwise back off exponentially from 0.5 s with ±50% jitter, capped at 10 s. `retries:` can be overridden per request
46
+ - Ingestion batches are serialized to JSON once and posted as a pre-encoded body; the per-event chunking path only runs when the batch exceeds the 3.5 MB limit (previously every batch was measured event by event and then re-encoded by Faraday)
47
+ - `*-update` events send only the fields that actually changed, plus the identifying ones, instead of the observation's full body. Ending a long generation no longer re-uploads its input and model parameters
48
+ - Enhanced observation wrappers (`agent`, `tool`, `chain`, `retriever`, `evaluator`, `guardrail`) are generated once in the new `Langfuse::SpanWrappers` module and shared by `Client`, `Trace`, `Span` and `Generation`, replacing 24 hand-written copies (~400 lines); an explicit `as_type:` passed by the caller no longer overrides the wrapper's type
49
+ - `Client#evaluator` is available as an alias of `Client#evaluator_obs`, so the helper has the same name as `Trace#evaluator` / `Span#evaluator` / `Generation#evaluator` (both names keep working)
50
+ - Placeholder compilation and variable extraction moved into the new `Langfuse::TemplateCompiler`, shared by `Prompt`, `PromptTemplate` and `ChatPromptTemplate` (previously four copies of the substitution loop and three of the variable scanner)
51
+ - `Trace`, `Span` and `Event` now serialize through `to_dict` when enqueuing `*-create` / `*-update` events instead of rebuilding the same hash in each method, so a field can no longer be added to one path and forgotten in the others
52
+ - `Utils.deep_stringify_keys` is now an alias of `Utils.deep_camelize_keys`: the two implementations were identical, and both camelize keys rather than only stringifying them
53
+
54
+ ## [0.2.0] - 2026-07-18
55
+
56
+ ### Added
57
+ - **Environment support**: `environment` config / `LANGFUSE_TRACING_ENVIRONMENT` env var, injected into trace, observation and score bodies
58
+ - **Sampling**: `sample_rate` config / `LANGFUSE_SAMPLE_RATE` env var, deterministic trace-based sampling (all events of a trace share the same decision)
59
+ - **Masking**: `mask` callable applied to `input`/`output`/`metadata` before sending, for PII redaction
60
+ - **flush_at threshold**: flush as soon as the queue reaches `flush_at` events (default 15, env `LANGFUSE_FLUSH_AT`), via a condition-variable wake-up on the flush thread
61
+ - **Batch chunking**: ingestion batches are split to respect the 3.5 MB API limit; oversized single events are dropped with a warning
62
+ - **207 partial-success handling**: per-event errors from the ingestion API are logged via the structured logger
63
+ - **Score full fields**: `session_id`, `dataset_run_id`, `metadata`, `config_id`, `queue_id`, `id`, `environment`, and string values for CATEGORICAL/CORRECTION scores; `create_score` alias
64
+ - **Generation usage_details / cost_details**: new v4 usage model (arbitrary keys such as cache tokens) alongside legacy `usage`
65
+ - **Generation prompt linking**: `prompt:` accepts a `Langfuse::Prompt` or `{ name:, version: }` hash, emitted as `promptName`/`promptVersion`
66
+ - **Trace public field**: `public:` flag for shareable traces
67
+ - **LANGFUSE_BASE_URL** env var alias (new SDK standard) alongside `LANGFUSE_HOST`
68
+ - **Structured Logger**: replaces `puts` with `Logger`; level controlled by `debug` / `LANGFUSE_DEBUG`
69
+ - **at_exit shutdown hook**: pending events are flushed on process exit (configurable via `shutdown_on_exit`)
70
+ - **W3C hex IDs in OTel mode**: native 32-char trace IDs and 16-char span IDs for OTel ingestion
71
+ - **OTel exporter attributes**: `langfuse.environment`, `langfuse.trace.public`, `langfuse.internal.as_root`, `langfuse.observation.usage_details`, `langfuse.observation.cost_details`, `langfuse.observation.prompt.name`, `langfuse.observation.prompt.version`
72
+ - **Simplified API**: Class-level convenience methods (`Langfuse.trace`, `get_prompt`, `client`, `flush`, `shutdown`, `reset!`) with graceful degradation via null objects
73
+ - **Retry Support**: `get_prompt` supports configurable retries with exponential backoff (default: 2 retries)
74
+ - **Ruby 4.0 support**: CI matrix covers Ruby 3.1–4.0; explicit `base64` / `tsort` dependencies for Ruby 4.0 gem packaging
75
+
76
+ ### Fixed
77
+ - **OTel mode scores lost**: scores were exported as OTLP spans with `langfuse.score.*` attributes, which the server does not map to Langfuse scores. Scores now always route through the ingestion API (`score-create` batch), with trace/observation IDs normalized to OTel hex IDs so they attach to the correct entities
78
+ - **OTel flush failure drops scores**: when OTEL export of non-score events fails, score events in the same batch are now re-queued together with OTEL events (they were previously drained by `flush` and permanently lost on `raise`)
79
+ - **OTel ID mismatch**: observation-level scores referenced full UUIDs while spans used truncated hex, breaking attachment. IDs are now normalized on both sides
80
+ - **Span/Generation score missing trace_id**: `Span#score` and `Generation#score` now pass `trace_id` so the server can attach observation-level scores correctly
81
+ - **Process-level singleton**: `Langfuse.client` was thread-local (`Thread.current`), creating one client + flush thread per thread under Puma/Sidekiq. Now a single process-wide client guarded by a `Mutex`
82
+ - **Idempotent shutdown**: `shutdown` can be called multiple times safely
83
+ - **Body serialization**: event bodies now only camelCase top-level keys; user data under `input`/`output`/`metadata`/`usageDetails`/`costDetails`/`modelParameters` is passed through verbatim so user-provided keys are not mangled
84
+
85
+ ### Changed
86
+ - `Langfuse.client` is now process-wide instead of thread-local. Use `Langfuse.new` for isolated clients in tests
87
+ - Default `flush_interval` behavior unchanged, but the flush thread now also wakes on the `flush_at` threshold
88
+ - `Configuration` gains `environment`, `sample_rate`, `mask`, `flush_at`, `logger`, `shutdown_on_exit` attributes
21
89
 
22
90
  ## [0.1.5] - 2025-12-26
23
91
 
data/CLAUDE.md CHANGED
@@ -8,8 +8,14 @@ This is the official Ruby SDK for [Langfuse](https://langfuse.com) - an open-sou
8
8
 
9
9
  ## Common Commands
10
10
 
11
+ Ruby version is managed with [mise](https://mise.jdx.dev) via `.mise.toml`
12
+ (defaults to the latest stable Ruby). Run `mise install` after cloning, then
13
+ `bundle install`. With mise's shell integration active, the commands below work
14
+ directly; in a non-mise shell prefix them with `mise exec --`.
15
+
11
16
  ```bash
12
17
  # Install dependencies
18
+ mise install # install the pinned Ruby version
13
19
  bundle install
14
20
 
15
21
  # Run all RSpec tests
@@ -30,8 +36,8 @@ bundle exec rubocop
30
36
  # Build the gem
31
37
  bundle exec rake build
32
38
 
33
- # Release to RubyGems
34
- bundle exec rake release_gem
39
+ # Release: bump version + CHANGELOG, then `make tag VERSION=x.y.z`
40
+ # (pushes v* tag; GitHub Actions publishes to RubyGems)
35
41
  ```
36
42
 
37
43
  ## Architecture
@@ -39,22 +45,33 @@ bundle exec rake release_gem
39
45
  ### Core Classes
40
46
 
41
47
  - **`Langfuse`** ([lib/langfuse.rb](lib/langfuse.rb)) - Module with class-level convenience methods (`trace`, `get_prompt`, `client`, `flush`, `shutdown`, `reset!`)
42
- - **`Langfuse::Client`** ([lib/langfuse/client.rb](lib/langfuse/client.rb)) - Main entry point. Handles API authentication, HTTP connections (via Faraday), event queuing, and background flush thread for auto-batching events.
48
+ - **`Langfuse::Client`** ([lib/langfuse/client.rb](lib/langfuse/client.rb)) - Main entry point. Handles API authentication, HTTP connections (via Faraday), event queuing, sampling/masking, and background flush thread for auto-batching events.
43
49
  - **`Langfuse::Trace`** ([lib/langfuse/trace.rb](lib/langfuse/trace.rb)) - Top-level container for a request/session.
44
50
  - **`Langfuse::Span`** ([lib/langfuse/span.rb](lib/langfuse/span.rb)) - Timed operation with enhanced type support.
45
- - **`Langfuse::Generation`** ([lib/langfuse/generation.rb](lib/langfuse/generation.rb)) - LLM call tracking.
51
+ - **`Langfuse::Generation`** ([lib/langfuse/generation.rb](lib/langfuse/generation.rb)) - LLM call tracking (supports `usage_details`/`cost_details` and prompt linking).
46
52
  - **`Langfuse::Event`** ([lib/langfuse/event.rb](lib/langfuse/event.rb)) - Point-in-time events.
47
- - **`Langfuse::Prompt`** ([lib/langfuse/prompt.rb](lib/langfuse/prompt.rb)) - Prompt templates with caching.
53
+ - **`Langfuse::SpanWrappers`** ([lib/langfuse/span_wrappers.rb](lib/langfuse/span_wrappers.rb)) - Generates the enhanced observation helpers (`agent`, `tool`, `chain`, `retriever`, `evaluator`, `guardrail`) for Client/Trace/Span/Generation as `#span` calls with a fixed `as_type`.
54
+ - **`Langfuse::Prompt`** ([lib/langfuse/prompt.rb](lib/langfuse/prompt.rb)) - Prompt templates.
55
+ - **`Langfuse::TemplateCompiler`** ([lib/langfuse/template_compiler.rb](lib/langfuse/template_compiler.rb)) - `{{var}}` / `{var}` substitution and variable extraction shared by `Prompt`, `PromptTemplate` and `ChatPromptTemplate`.
56
+ - **`Langfuse::PromptCache`** ([lib/langfuse/prompt_cache.rb](lib/langfuse/prompt_cache.rb)) - Bounded, thread-safe prompt cache used by `Client#get_prompt`: monotonic-clock TTLs, oldest-first eviction, stale reads for outage fallback.
57
+ - **`Langfuse::PartialUpdates`** ([lib/langfuse/partial_updates.rb](lib/langfuse/partial_updates.rb)) - Dirty tracking for `Trace`/`Span`/`Generation`: `update`/`end` record which fields the caller passed, and `update_body` slices `to_dict` down to those plus the identifying fields (`id`, `trace_id`, `type`).
58
+ - **`Langfuse::OtelExporter`** ([lib/langfuse/otel_exporter.rb](lib/langfuse/otel_exporter.rb)) - Maps Langfuse events to OTLP/HTTP JSON when `ingestion_mode: :otel`.
48
59
  - **`Langfuse::NullTrace/NullGeneration/NullSpan`** ([lib/langfuse/null_objects.rb](lib/langfuse/null_objects.rb)) - Null objects for graceful degradation.
49
60
 
61
+ New projects should set `ingestion_mode: :otel` (Langfuse v4 / OTLP). The
62
+ default remains `:legacy` for compatibility; Cloud stops accepting non-score
63
+ `/api/public/ingestion` traffic on 16 November 2026. See [docs/V4.md](docs/V4.md).
64
+
50
65
  ### Simplified API (Recommended)
51
66
 
52
67
  ```ruby
68
+ Langfuse.configure { |c| c.ingestion_mode = :otel } # Langfuse v4
69
+
53
70
  # Block-based tracing with automatic flush
54
71
  Langfuse.trace("my-trace", user_id: "user-1") do |trace|
55
72
  gen = trace.generation(name: "openai", model: "gpt-4", input: messages)
56
73
  response = call_llm(...)
57
- gen.end(output: response, usage: usage)
74
+ gen.end(output: response, usage_details: { input: 10, output: 20, total: 30 })
58
75
  end # Auto flush!
59
76
 
60
77
  # Get prompt with variables and retry
@@ -63,10 +80,17 @@ Langfuse.get_prompt("my-prompt", variables: { name: "Alice" }, retries: 3)
63
80
 
64
81
  ### Event Flow
65
82
 
66
- 1. Observations (traces, spans, generations, events) are created via Client methods
67
- 2. Events are queued in `@event_queue` (thread-safe `Concurrent::Array`)
68
- 3. Background flush thread sends batched events to `/api/public/ingestion` endpoint
69
- 4. Manual flush available via `client.flush`; graceful shutdown via `client.shutdown`
83
+ 1. Observations (traces, spans, generations, events, scores) are created via Client methods
84
+ 2. Bodies are prepared via `Utils.prepare_event_body` (top-level camelCase; user data under `input`/`output`/`metadata`/etc. is left verbatim), then environment injection, masking, and sampling are applied
85
+ 3. Events are queued in `@event_queue` (thread-safe `Concurrent::Array`, guarded by `@queue_mutex`). The queue is bounded by `max_queue_size` (default 10,000); when full, the oldest events are dropped with a rate-limited warning. `trace-update` events merge into the queued `trace-create` under the same lock; if the create has already flushed, the update is converted to a create from `trace_ref.to_dict` and re-run through the same camelCase / environment / mask path
86
+ 4. Background flush thread wakes on `flush_interval` **or** when the queue reaches `flush_at`. It is recreated after `fork` (events inherited from the parent are dropped; the parent still flushes them) and stopped gracefully on `shutdown` (signalled, joined with a 5 s grace period, killed only as a fallback)
87
+ 5. `*-update` events carry only the changed fields (see `PartialUpdates`), so a long generation's input is not re-sent when it ends
88
+ 6. Flush path depends on `ingestion_mode` (both paths chunk to 3.5 MB):
89
+ - `:legacy` → batched POST to `/api/public/ingestion`
90
+ - `:otel` → non-score events via OTLP `/api/public/otel/v1/traces`, after collapsing each observation's `*-create`/`*-update` events into one span (v4 is append-only, so exporting both would duplicate the observation); **scores always go through the ingestion API**, with IDs normalized to OTel hex so they attach correctly. On OTEL transport failure, the not-yet-sent chunks and the batch's score events are re-queued; `partialSuccess` responses are logged as warnings
91
+ - On failure both paths re-queue only for transient errors; permanent failures (4xx validation/auth) drop the batch with a warning so it cannot block the queue forever
92
+ - The legacy batch is serialized to JSON once and posted as a String; the per-event chunking path only runs when that payload exceeds 3.5 MB
93
+ 7. Manual flush via `client.flush`; idempotent shutdown via `client.shutdown` (plus optional `at_exit` hook)
70
94
 
71
95
  ### Observation Types
72
96
 
@@ -81,20 +105,34 @@ Enhanced types are implemented as spans with `as_type` metadata sent to the API.
81
105
  Client accepts config via:
82
106
  1. Constructor parameters
83
107
  2. `Langfuse.configure` block
84
- 3. Environment variables: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_HOST`, `LANGFUSE_FLUSH_INTERVAL`, `LANGFUSE_AUTO_FLUSH`
108
+ 3. Environment variables: `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_HOST` / `LANGFUSE_BASE_URL`, `LANGFUSE_FLUSH_INTERVAL`, `LANGFUSE_FLUSH_AT`, `LANGFUSE_MAX_QUEUE_SIZE`, `LANGFUSE_AUTO_FLUSH`, `LANGFUSE_TRACING_ENVIRONMENT`, `LANGFUSE_SAMPLE_RATE`, `LANGFUSE_DEBUG`, `LANGFUSE_INGESTION_MODE` (`otel` for v4, `legacy` for pre-v4)
109
+
110
+ Default host is `https://us.cloud.langfuse.com`. Default `ingestion_mode` is `:legacy`; use `:otel` for Langfuse v4.
111
+
112
+ `http_adapter` is constructor/`configure`-only (no env var), since the adapter has to be in the app's `Gemfile` anyway.
85
113
 
86
114
  ### Error Handling
87
115
 
88
116
  Custom exceptions in [lib/langfuse/errors.rb](lib/langfuse/errors.rb):
89
117
  - `AuthenticationError`, `APIError`, `NetworkError`, `ValidationError`, `RateLimitError`, `TimeoutError`
90
118
 
91
- Graceful degradation: When Langfuse is unavailable, `Langfuse.trace` yields a `NullTrace` that silently no-ops all operations.
119
+ Graceful degradation: When trace creation fails, `Langfuse.trace` yields a `NullTrace` that silently no-ops all operations. Only creation is rescued — an exception raised by the block propagates and the block is never re-run (re-running it would duplicate LLM calls).
120
+
121
+ Retries live in `Client#request` only: `TimeoutError`, `NetworkError`, `RateLimitError` and 5xx `APIError` are retried up to `retries` times (per-call override via `retries:`), honoring `Retry-After` (seconds or HTTP date, capped at `MAX_RETRY_DELAY_SECONDS`) and otherwise backing off exponentially from `RETRY_BASE_DELAY_SECONDS` with ±50% jitter. `Langfuse.get_prompt` passes `retries:` down instead of running its own loop (the two used to multiply).
92
122
 
93
123
  ## Key Implementation Details
94
124
 
95
- - Uses Faraday for HTTP with Basic Auth (public_key:secret_key)
125
+ - Uses Faraday for HTTP with Basic Auth (public_key:secret_key); `http_adapter` selects the adapter (e.g. `:net_http_persistent` for keep-alive) and falls back to `Faraday.default_adapter` with a warning when the adapter's gem is missing
96
126
  - Prompt names with special characters are auto-URL-encoded via `Utils.url_encode`
97
- - `trace-update` events merge into existing `trace-create` in queue (deduplication)
98
- - All keys are converted to camelCase before API submission via `Utils.deep_camelize_keys`
99
- - Thread-safe singleton client via `Thread.current[:langfuse_client]`
127
+ - `trace-update` events merge into the queued `trace-create` under `@queue_mutex` (deduplication without flush races). A reconstruction from `trace_ref` (create already flushed) goes through `prepare_queued_body` so keys stay camelCase, the default environment is injected, and the mask still applies
128
+ - `get_prompt` caches via `PromptCache`: on refetch failure the stale entry is served with a warning rather than raising
129
+ - `Client#inspect` redacts the secret key
130
+ - The enhanced observation wrappers are generated by `SpanWrappers#define_span_wrappers` (Client names the evaluator helper `evaluator_obs` and aliases `evaluator` to it); `embedding` stays hand-written in each class because it folds model/usage into metadata
131
+ - `Trace`/`Span`/`Event` enqueue `to_dict`, so `to_dict` is the single place a body field is defined (`Generation` already worked this way)
132
+ - `TemplateCompiler.compile` substitutes all variables in one pass over the template, so a variable value containing `{{...}}` is never expanded again; unknown placeholders are left untouched
133
+ - Event bodies use `Utils.prepare_event_body` (top-level camelCase only; nested user data is not mangled)
134
+ - Process-wide singleton client via `Langfuse::CLIENT_MUTEX` (not thread-local); use `Langfuse.new` for isolated clients in tests
135
+ - In OTel mode, IDs are W3C hex (`generate_trace_id` / `generate_observation_id`); scores normalize refs via `OtelExporter.to_otel_trace_id` / `to_otel_span_id`
136
+ - In OTel mode, a legacy `usage` object is normalized into `langfuse.observation.usage_details` (v4's cost model) unless `usage_details` was set explicitly; non-token `unit`s are skipped
137
+ - Typed errors from `handle_response` keep their class through `#request`; only unexpected errors are wrapped in `APIError`
100
138
  - `get_prompt` supports configurable retries with exponential backoff
data/Gemfile CHANGED
@@ -7,4 +7,5 @@ gemspec
7
7
 
8
8
  gem 'rake', '~> 13.0'
9
9
  gem 'rspec', '~> 3.0'
10
+ gem 'simplecov', '~> 0.22', require: false
10
11
  gem 'yard', '~> 0.9'
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- langfuse-ruby (0.1.7)
4
+ langfuse-ruby (0.2.1)
5
+ base64 (>= 0.1.0)
5
6
  concurrent-ruby (~> 1.0)
6
7
  faraday (>= 1.8, < 3.0)
7
8
  faraday-multipart (~> 1.0)
@@ -21,11 +22,12 @@ GEM
21
22
  bigdecimal
22
23
  rexml
23
24
  diff-lcs (1.6.2)
25
+ docile (1.4.1)
24
26
  faraday (2.13.2)
25
27
  faraday-net_http (>= 2.0, < 3.5)
26
28
  json
27
29
  logger
28
- faraday-multipart (1.1.1)
30
+ faraday-multipart (1.2.0)
29
31
  multipart-post (~> 2.0)
30
32
  faraday-net_http (3.4.1)
31
33
  net-http (>= 0.5.0)
@@ -76,12 +78,18 @@ GEM
76
78
  parser (>= 3.3.7.2)
77
79
  prism (~> 1.4)
78
80
  ruby-progressbar (1.13.0)
81
+ simplecov (0.22.0)
82
+ docile (~> 1.1)
83
+ simplecov-html (~> 0.11)
84
+ simplecov_json_formatter (~> 0.1)
85
+ simplecov-html (0.13.2)
86
+ simplecov_json_formatter (0.1.4)
87
+ tsort (0.2.0)
79
88
  unicode-display_width (3.1.4)
80
89
  unicode-emoji (~> 4.0, >= 4.0.4)
81
- unicode-emoji (4.0.4)
90
+ unicode-emoji (4.2.0)
82
91
  uri (1.0.3)
83
- vcr (6.3.1)
84
- base64
92
+ vcr (6.4.0)
85
93
  webmock (3.25.1)
86
94
  addressable (>= 2.8.0)
87
95
  crack (>= 0.3.2)
@@ -98,6 +106,8 @@ DEPENDENCIES
98
106
  rake (~> 13.0)
99
107
  rspec (~> 3.0)
100
108
  rubocop (~> 1.0)
109
+ simplecov (~> 0.22)
110
+ tsort (>= 0.1.0)
101
111
  vcr (~> 6.0)
102
112
  webmock (~> 3.0)
103
113
  yard (~> 0.9)
data/Makefile CHANGED
@@ -1,4 +1,4 @@
1
- .PHONY: install test spec test-offline test-all lint lint-fix build release clean console help tag
1
+ .PHONY: install test spec test-offline test-all lint lint-fix build release clean console help tag coverage
2
2
 
3
3
  help: ## Show this help
4
4
  @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
@@ -17,6 +17,10 @@ test-all: ## Run all tests (spec + offline)
17
17
 
18
18
  test: spec ## Alias for spec
19
19
 
20
+ coverage: ## Run tests and open coverage report
21
+ bundle exec rake spec
22
+ open coverage/index.html
23
+
20
24
  lint: ## Run RuboCop linter
21
25
  bundle exec rubocop
22
26
 
@@ -26,9 +30,6 @@ lint-fix: ## Run RuboCop with auto-correct
26
30
  build: ## Build the gem
27
31
  bundle exec rake build
28
32
 
29
- release: tag ## Release: tag + build + push to RubyGems
30
- bundle exec rake release_gem
31
-
32
33
  clean: ## Remove built gem files
33
34
  rm -f langfuse-ruby-*.gem
34
35
  rm -rf pkg/
@@ -36,7 +37,7 @@ clean: ## Remove built gem files
36
37
  console: ## Start an IRB console with the gem loaded
37
38
  bundle exec irb -r langfuse
38
39
 
39
- tag: ## Create and push a version tag. Usage: make tag [VERSION=x.y.z]
40
+ tag: ## Create and push a version tag. Usage: make tag VERSION=x.y.z
40
41
  @git fetch --tags; \
41
42
  if [ -z "$(VERSION)" ]; then \
42
43
  LATEST=$$(git tag -l 'v[0-9]*' --sort=-v:refname | head -n1); \