Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ Langfuse.tracer_provider # => OpenTelemetry::SDK::Trace::TracerProvider

`Langfuse.configure` does not call this for you. This is the explicit global-install seam. If you also want another OpenTelemetry backend or custom propagation, that remains application-owned setup.

`Langfuse::OtelSetup` remains as a deprecated compatibility wrapper around the global `Langfuse.tracer_provider`, `Langfuse.force_flush`, and `Langfuse.shutdown` APIs. New code should not call it; explicit clients should use `Langfuse::Client.new(config).tracer_provider`.

**Example:**

```ruby
Expand Down Expand Up @@ -207,6 +209,21 @@ client = Langfuse.client
prompt = client.get_prompt("greeting")
```

### `Langfuse::Client.new`

Create an explicit client. Use this when one Ruby process needs more than one Langfuse project.

```ruby
client = Langfuse::Client.new(
Langfuse::Config.new do |config|
config.public_key = ENV["LANGFUSE_PUBLIC_KEY"]
config.secret_key = ENV["LANGFUSE_SECRET_KEY"]
end
)
```

Explicit clients own their own API client, score queue, prompt cache, and tracer provider. Observations created by an explicit client keep that owner for child observations, masking, trace URLs, scores, `force_flush`, and `shutdown`.

## Prompt Management

### `Client#get_prompt`
Expand Down Expand Up @@ -671,6 +688,28 @@ obs.update(output: { result: "done" })
obs.end
```

### `Client#observe`

Explicit-client tracing equivalent to `Langfuse.observe`.

```ruby
client.observe("operation", { input: "data" }) do |obs|
child = obs.start_observation("child")
child.end
end
```

Use this instead of `Langfuse.observe` when the trace belongs to an explicit client. Children created from the returned observation stay on the same client.

### `Client#start_observation`

Explicit-client equivalent to `Langfuse.start_observation`.

```ruby
obs = client.start_observation("operation", { input: "data" })
obs.end
```

### `BaseObservation`

Returned by `observe` in stateful mode or passed to block.
Expand All @@ -684,6 +723,7 @@ Returned by `observe` in stateful mode or passed to block.
| `trace_url` | `String` or `nil` | URL to Langfuse UI, if project lookup succeeds |
| `otel_span` | OpenTelemetry::SDK::Trace::Span | Underlying OTel span |
| `type` | String | Observation type |
| `client` | Langfuse::Client | Client that owns follow-up operations |

**Methods:**

Expand Down Expand Up @@ -1001,7 +1041,8 @@ Langfuse.client.flush_scores

### Module-Level Scoring

Convenience methods delegating to `Langfuse.client`:
Convenience methods. Active scoring routes through the client that owns the current Langfuse observation,
falling back to `Langfuse.client` only outside Langfuse-owned observations:

```ruby
Langfuse.create_score(name: "quality", value: 0.85, trace_id: "abc")
Expand Down Expand Up @@ -1546,7 +1587,7 @@ Langfuse.shutdown

### `Langfuse.force_flush`

Force flush all pending data.
Force flush pending traces for the singleton client.

**Signature:**

Expand All @@ -1560,6 +1601,8 @@ Langfuse.force_flush(timeout: 30)
Langfuse.force_flush(timeout: 10)
```

Use `client.force_flush(timeout: 10)` for traces created through an explicit client.

## See Also

- [GETTING_STARTED.md](GETTING_STARTED.md) - Quick start guide
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ span.end
**Key Components:**

- **BaseObservation** - Base class for all observation types
- **OtelSetup** - Initializes OpenTelemetry SDK with OTLP exporter
- **Client tracer provider** - Owns isolated OpenTelemetry export for each client
- **SpanProcessor** - Propagates trace-level attributes to child spans
- **OtelAttributes** - Converts Langfuse attributes to OpenTelemetry format

Expand Down
49 changes: 46 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ This is the part people get wrong.
- `Langfuse.configure` stores configuration only.
- Module-level tracing initializes lazily on first use.
- Langfuse tracing is isolated by default.
- `Langfuse.tracer_provider` is the explicit seam for installing Langfuse as the global OpenTelemetry provider.
- `Langfuse.tracer_provider` returns the singleton client's provider for explicit global OpenTelemetry installation.
- `should_export_span` only runs on spans handled by Langfuse's provider.
- Filtering is not the fix for ambient-span overcapture. Isolation is.
- Langfuse does not auto-configure a second OpenTelemetry backend or any multi-export pipeline for you.
Expand All @@ -50,6 +50,49 @@ OpenTelemetry.tracer_provider = Langfuse.tracer_provider

If you also want propagation or another OpenTelemetry backend, configure those in your application. Langfuse does not infer or install them.

## Multiple Clients

Use explicit `Langfuse::Client` instances when one Ruby process sends data to multiple Langfuse projects. The module-level APIs are only a facade over `Langfuse.client`; explicit clients own their own API client, score queue, prompt cache, and tracer provider.

```ruby
primary = Langfuse.client

project_a_config = Langfuse::Config.new do |config|
config.public_key = ENV["LANGFUSE_PROJECT_A_PUBLIC_KEY"]
config.secret_key = ENV["LANGFUSE_PROJECT_A_SECRET_KEY"]
end

project_b_config = Langfuse::Config.new do |config|
config.public_key = ENV["LANGFUSE_PROJECT_B_PUBLIC_KEY"]
config.secret_key = ENV["LANGFUSE_PROJECT_B_SECRET_KEY"]
end

project_a = Langfuse::Client.new(project_a_config)
project_b = Langfuse::Client.new(project_b_config)

project_a.observe("project-a-workflow") do |root|
root.start_observation("project-a-child")
end

project_b.observe("project-b-workflow") do |root|
root.start_observation("project-b-child")
end

project_a.force_flush
project_b.force_flush
```

The owner is sticky. A root observation created by `project_a.observe` creates children through `project_a`, uses `project_a.config.mask`, generates trace URLs through `project_a`, and scores through `project_a`. It does not fall back to `Langfuse.client`.

Use lifecycle methods on the same client that created the work:

```ruby
project_a.force_flush(timeout: 10)
project_a.shutdown(timeout: 30)
```

`Langfuse.force_flush` and `Langfuse.shutdown` apply to the singleton client only.

## All Configuration Options

### Required
Expand Down Expand Up @@ -401,12 +444,12 @@ There are three states worth documenting.

- `Langfuse.configure` does not mutate `OpenTelemetry.tracer_provider`
- `Langfuse.configure` does not mutate `OpenTelemetry.propagation`
- `Langfuse.observe(...)` uses Langfuse's internal tracer provider once tracing is ready
- `Langfuse.observe(...)` uses the singleton client's internal tracer provider once tracing is ready
- if `public_key`, `secret_key`, or `base_url` are missing, module-level tracing falls back to a no-op tracer and logs one warning

### Explicit Global Install with `Langfuse.tracer_provider`

If you want Langfuse to own the global OpenTelemetry provider, install it explicitly:
If you want the singleton Langfuse client to own the global OpenTelemetry provider, install it explicitly:

```ruby
require "opentelemetry/trace/propagation/trace_context"
Expand Down
6 changes: 6 additions & 0 deletions docs/SCORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ end
```

This is useful when you don't have the observation ID but want to score from within the traced block.
Inside a Langfuse observation, module-level scoring uses the client that owns that observation. That keeps
explicit-client traces from accidentally sending scores through the singleton client.

### Scoring Active Traces

Expand All @@ -165,6 +167,10 @@ Langfuse.observe("user-request") do |span|
end
```

When called from a raw OpenTelemetry span that was not created by Langfuse, module-level active scoring still
falls back to `Langfuse.client` for this release and emits a deprecation warning. Prefer
`client.score_active_trace` or `client.score_active_observation` for raw OTel spans so the score owner is explicit.

## Complete Examples

### User Feedback (Thumbs Up/Down)
Expand Down
94 changes: 91 additions & 3 deletions docs/TRACING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,39 @@ This guide is about the tracing behavior the SDK actually implements today. If y
- Child observations create nested spans inside that trace.
- `:generation` is the right type for model calls because it carries model-specific fields like `model`, `usage_details`, and `cost_details`.
- `:event` is a point-in-time observation with no duration.
- `Langfuse.configure` stores configuration only. Module-level tracing uses Langfuse's internal tracer provider when tracing is ready.
- `Langfuse.configure` stores configuration only. Module-level tracing uses the singleton client's internal tracer provider when tracing is ready.

## Singleton vs Explicit Clients

`Langfuse.observe(...)` is shorthand for `Langfuse.client.observe(...)`. That is the right default when your process sends traces to one Langfuse project.

When one Ruby process needs multiple Langfuse projects, build explicit clients and call tracing APIs on those clients:

```ruby
project_a = Langfuse::Client.new(
Langfuse::Config.new do |config|
config.public_key = ENV["LANGFUSE_PROJECT_A_PUBLIC_KEY"]
config.secret_key = ENV["LANGFUSE_PROJECT_A_SECRET_KEY"]
end
)

project_b = Langfuse::Client.new(
Langfuse::Config.new do |config|
config.public_key = ENV["LANGFUSE_PROJECT_B_PUBLIC_KEY"]
config.secret_key = ENV["LANGFUSE_PROJECT_B_SECRET_KEY"]
end
)

project_a.observe("ingest-document") do |root|
root.start_observation("extract-text")
end

project_b.observe("support-answer") do |root|
root.start_observation("openai-chat", as_type: :generation)
end
```

Observations keep their owner. A child created from `project_a` stays on `project_a` for tracing, masking, trace URLs, scores, flushing, and shutdown. Do not mix `Langfuse.observe` into an explicit-client trace unless you intentionally want the singleton client's project.

## Start with a Root Observation

Expand Down Expand Up @@ -217,14 +249,14 @@ This is the default behavior:

- `Langfuse.configure` does not mutate `OpenTelemetry.tracer_provider`
- `Langfuse.configure` does not mutate `OpenTelemetry.propagation`
- `Langfuse.observe(...)` uses Langfuse's internal tracer provider once tracing is configured
- `Langfuse.observe(...)` uses the singleton client's internal tracer provider once tracing is configured
- if tracing config is incomplete, module-level tracing falls back to a no-op tracer and logs one warning

This is why ambient spans from some unrelated global OpenTelemetry provider are not exported to Langfuse by default.

### 2. Explicit Global Install with `Langfuse.tracer_provider`

If you want Langfuse to own the global OpenTelemetry provider, install it explicitly:
If you want the singleton Langfuse client to own the global OpenTelemetry provider, install it explicitly:

```ruby
Langfuse.configure do |config|
Expand Down Expand Up @@ -287,6 +319,62 @@ Public helper predicates:

The exact signatures live in [API_REFERENCE.md](API_REFERENCE.md).

## Validate Against Langfuse

For changes to tracing behavior, validate both the local SDK behavior and the server-side Langfuse result. Use one small Ruby script to emit traces, then read the traces back with the Langfuse CLI.

```ruby
# scratchpad/validate_multiple_clients.rb
require "bundler/setup"
require "langfuse"

def build_client(public_key:, secret_key:, base_url:)
Langfuse::Client.new(
Langfuse::Config.new do |config|
config.public_key = public_key
config.secret_key = secret_key
config.base_url = base_url
config.tracing_async = false
end
)
end

client = build_client(
public_key: ENV.fetch("LANGFUSE_PUBLIC_KEY"),
secret_key: ENV.fetch("LANGFUSE_SECRET_KEY"),
base_url: ENV.fetch("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
)

trace_id = nil
client.observe("ruby-sdk-validation", input: { source: "langfuse-rb" }) do |span|
trace_id = span.trace_id
span.start_observation("child-generation", { model: "test-model" }, as_type: :generation) do |generation|
generation.update(output: "ok")
end
span.score_trace(name: "validation-score", value: 1.0)
end

client.force_flush(timeout: 10)
client.flush_scores
puts trace_id
```

Run it locally:

```bash
trace_id=$(bundle exec ruby scratchpad/validate_multiple_clients.rb)
```

Then read the same trace back from Langfuse:

```bash
npx --yes langfuse-cli api traces get "$trace_id" --fields core,observations,scores --json
npx --yes langfuse-cli api observations list --trace-id "$trace_id" --fields core,basic,metadata --json
npx --yes langfuse-cli api scores list --trace-id "$trace_id" --fields score,trace --json
```

For multiple clients, run the script once per project credential set and confirm each trace appears only in the expected project. The CLI readback is the proof; local object identity alone only proves the Ruby process behavior.

## Best Practices

- Put workflow-level output on the root observation and model-level output on the generation.
Expand Down
Loading
Loading