Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- `:resolve` option for `Langfuse.Prompt.get/2`, `Langfuse.Prompt.fetch/2`, `Langfuse.Client.get_prompt/2`, and `Langfuse.HTTP.get_prompt/2` to control server-side prompt dependency resolution
- `:cacertfile` config option and `LANGFUSE_CACERTFILE` env var for custom CA certificates (self-hosted Langfuse with self-signed certs)
- GitHub Actions CI with matrix testing across Elixir 1.17/OTP 26, 1.18/OTP 27, and 1.19/OTP 28

## [0.1.0] - 2025-11-29
Expand Down
13 changes: 9 additions & 4 deletions lib/langfuse/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,11 @@ defmodule Langfuse.Client do
if Config.configured?() do
url = config.host <> path

case Req.delete(url, auth: {:basic, "#{config.public_key}:#{config.secret_key}"}) do
case Req.delete(
url,
[auth: {:basic, "#{config.public_key}:#{config.secret_key}"}] ++
HTTP.ssl_options(config)
) do
{:ok, %Req.Response{status: status}} when status in 200..299 ->
:ok

Expand Down Expand Up @@ -1039,9 +1043,10 @@ defmodule Langfuse.Client do
if Config.configured?() do
url = config.host <> path

case Req.patch(url,
json: body,
auth: {:basic, "#{config.public_key}:#{config.secret_key}"}
case Req.patch(
url,
[json: body, auth: {:basic, "#{config.public_key}:#{config.secret_key}"}] ++
HTTP.ssl_options(config)
) do
{:ok, %Req.Response{status: status, body: resp_body}} when status in 200..299 ->
{:ok, resp_body}
Expand Down
15 changes: 11 additions & 4 deletions lib/langfuse/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ defmodule Langfuse.Config do
* `LANGFUSE_SECRET_KEY` - API secret key
* `LANGFUSE_HOST` - Langfuse server URL
* `LANGFUSE_ENVIRONMENT` - Environment name (e.g., "production", "staging")
* `LANGFUSE_CACERTFILE` - Path to a custom CA certificate PEM file

## Configuration Options

Expand All @@ -46,6 +47,8 @@ defmodule Langfuse.Config do
* `:debug` - Whether debug logging is enabled. Defaults to `false`.
When enabled, logs detailed information about HTTP requests, batching,
and event processing. Useful for troubleshooting integration issues.
* `:cacertfile` - Path to a custom CA certificate PEM file for
self-hosted Langfuse instances with self-signed certificates.

## Self-Hosted Langfuse

Expand All @@ -54,7 +57,8 @@ defmodule Langfuse.Config do
config :langfuse,
host: "https://langfuse.mycompany.com",
public_key: "pk-...",
secret_key: "sk-..."
secret_key: "sk-...",
cacertfile: "/etc/ssl/langfuse-root-ca.pem"

"""

Expand All @@ -74,7 +78,8 @@ defmodule Langfuse.Config do
:batch_size,
:max_retries,
:enabled,
:debug
:debug,
:cacertfile
]

@typedoc """
Expand All @@ -92,7 +97,8 @@ defmodule Langfuse.Config do
batch_size: pos_integer(),
max_retries: non_neg_integer(),
enabled: boolean(),
debug: boolean()
debug: boolean(),
cacertfile: String.t() | nil
}

@doc false
Expand Down Expand Up @@ -227,7 +233,8 @@ defmodule Langfuse.Config do
batch_size: get_integer(:batch_size) || @default_batch_size,
max_retries: get_integer(:max_retries) || @default_max_retries,
enabled: get_boolean(:enabled, true),
debug: get_boolean(:debug, false)
debug: get_boolean(:debug, false),
cacertfile: get_value(:cacertfile, "LANGFUSE_CACERTFILE")
}
end

Expand Down
9 changes: 9 additions & 0 deletions lib/langfuse/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ defmodule Langfuse.HTTP do
receive_timeout: 30_000
]
|> Keyword.merge(retry_options(config))
|> Keyword.merge(ssl_options(config))
|> Keyword.merge(opts)
|> Req.request()
|> handle_response()
Expand All @@ -178,6 +179,14 @@ defmodule Langfuse.HTTP do
result
end

@doc false
@spec ssl_options(Config.t()) :: keyword()
def ssl_options(%{cacertfile: path}) when is_binary(path) do
[connect_options: [transport_opts: [cacertfile: path]]]
end

def ssl_options(_config), do: []

defp retry_options(config) do
[
retry: :transient,
Expand Down
19 changes: 18 additions & 1 deletion lib/langfuse/open_telemetry/setup.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ defmodule Langfuse.OpenTelemetry.Setup do
* `:host` - Langfuse host (default: from config)
* `:public_key` - Public API key (default: from config)
* `:secret_key` - Secret API key (default: from config)
* `:cacertfile` - Path to a PEM-encoded CA certificate file for self-hosted
Langfuse instances with self-signed certificates (default: from config)

## Examples

Expand All @@ -56,7 +58,8 @@ defmodule Langfuse.OpenTelemetry.Setup do
config :opentelemetry_exporter,
otlp_protocol: config[:otlp_protocol],
otlp_endpoint: config[:otlp_endpoint],
otlp_headers: config[:otlp_headers]
otlp_headers: config[:otlp_headers],
ssl_options: config[:ssl_options]

"""
@spec exporter_config(keyword()) :: keyword()
Expand All @@ -66,6 +69,7 @@ defmodule Langfuse.OpenTelemetry.Setup do
host = Keyword.get(opts, :host, config.host || "https://cloud.langfuse.com")
public_key = Keyword.get(opts, :public_key, config.public_key)
secret_key = Keyword.get(opts, :secret_key, config.secret_key)
cacertfile = Keyword.get(opts, :cacertfile, config.cacertfile)

auth = Base.encode64("#{public_key}:#{secret_key}")

Expand All @@ -74,6 +78,7 @@ defmodule Langfuse.OpenTelemetry.Setup do
otlp_endpoint: "#{host}/api/public/otel/v1/traces",
otlp_headers: [{"Authorization", "Basic #{auth}"}]
]
|> maybe_put(:ssl_options, exporter_ssl_options(cacertfile))
end

@doc """
Expand All @@ -87,6 +92,8 @@ defmodule Langfuse.OpenTelemetry.Setup do
* `:host` - Langfuse host (default: from config)
* `:public_key` - Public API key (default: from config)
* `:secret_key` - Secret API key (default: from config)
* `:cacertfile` - Path to a PEM-encoded CA certificate file for self-hosted
Langfuse instances with self-signed certificates (default: from config)

## Examples

Expand All @@ -106,6 +113,7 @@ defmodule Langfuse.OpenTelemetry.Setup do
Application.put_env(:opentelemetry_exporter, :otlp_protocol, config[:otlp_protocol])
Application.put_env(:opentelemetry_exporter, :otlp_endpoint, config[:otlp_endpoint])
Application.put_env(:opentelemetry_exporter, :otlp_headers, config[:otlp_headers])
put_optional_env(:opentelemetry_exporter, :ssl_options, config[:ssl_options])

:ok
end
Expand Down Expand Up @@ -213,4 +221,13 @@ defmodule Langfuse.OpenTelemetry.Setup do
catch
_, _ -> false
end

defp exporter_ssl_options(nil), do: nil
defp exporter_ssl_options(path) when is_binary(path), do: [cacertfile: path]

defp maybe_put(config, _key, nil), do: config
defp maybe_put(config, key, value), do: Keyword.put(config, key, value)

defp put_optional_env(app, key, nil), do: Application.delete_env(app, key)
defp put_optional_env(app, key, value), do: Application.put_env(app, key, value)
end
30 changes: 29 additions & 1 deletion test/langfuse/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ defmodule Langfuse.ConfigTest do
batch_size: Application.get_env(:langfuse, :batch_size),
max_retries: Application.get_env(:langfuse, :max_retries),
enabled: Application.get_env(:langfuse, :enabled),
debug: Application.get_env(:langfuse, :debug)
debug: Application.get_env(:langfuse, :debug),
cacertfile: Application.get_env(:langfuse, :cacertfile)
}

on_exit(fn ->
Expand Down Expand Up @@ -246,5 +247,32 @@ defmodule Langfuse.ConfigTest do
System.delete_env("LANGFUSE_ENVIRONMENT")
Config.reload()
end

test "env var overrides app config for cacertfile" do
Application.put_env(:langfuse, :cacertfile, "/app/ca.pem")
System.put_env("LANGFUSE_CACERTFILE", "/env/ca.pem")
Config.reload()

assert Config.get(:cacertfile) == "/env/ca.pem"

System.delete_env("LANGFUSE_CACERTFILE")
Config.reload()
end
end

describe "cacertfile" do
test "returns nil by default" do
Application.delete_env(:langfuse, :cacertfile)
Config.reload()

assert Config.get(:cacertfile) == nil
end

test "returns configured path" do
Application.put_env(:langfuse, :cacertfile, "/path/to/ca-cert.pem")
Config.reload()

assert Config.get(:cacertfile) == "/path/to/ca-cert.pem"
end
end
end
15 changes: 15 additions & 0 deletions test/langfuse/http_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,19 @@ defmodule Langfuse.HTTPTest do
assert {:error, :not_configured} = Langfuse.HTTP.post("/api/public/test", %{})
end
end

describe "ssl_options/1" do
test "returns empty list when cacertfile is nil" do
config = %Langfuse.Config{cacertfile: nil}
assert Langfuse.HTTP.ssl_options(config) == []
end

test "returns connect_options when cacertfile is set" do
config = %Langfuse.Config{cacertfile: "/path/to/ca.pem"}

assert Langfuse.HTTP.ssl_options(config) == [
connect_options: [transport_opts: [cacertfile: "/path/to/ca.pem"]]
]
end
end
end
2 changes: 2 additions & 0 deletions test/langfuse/ingestion_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Langfuse.IngestionTest do
Application.delete_env(:langfuse, :event_handler)

Langfuse.Config.reload()
capture_log(fn -> Ingestion.flush() end)

on_exit(fn ->
Enum.each(original_config, fn {key, value} ->
Expand All @@ -34,6 +35,7 @@ defmodule Langfuse.IngestionTest do
end)

Langfuse.Config.reload()
capture_log(fn -> Ingestion.flush() end)
end)

:ok
Expand Down
74 changes: 73 additions & 1 deletion test/langfuse/open_telemetry/setup_test.exs
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
defmodule Langfuse.OpenTelemetry.SetupTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false

alias Langfuse.Config
alias Langfuse.OpenTelemetry.Setup

setup do
original_cacertfile = Application.get_env(:langfuse, :cacertfile)

original_exporter_config = %{
otlp_protocol: Application.get_env(:opentelemetry_exporter, :otlp_protocol),
otlp_endpoint: Application.get_env(:opentelemetry_exporter, :otlp_endpoint),
otlp_headers: Application.get_env(:opentelemetry_exporter, :otlp_headers),
ssl_options: Application.get_env(:opentelemetry_exporter, :ssl_options)
}

on_exit(fn ->
if original_cacertfile do
Application.put_env(:langfuse, :cacertfile, original_cacertfile)
else
Application.delete_env(:langfuse, :cacertfile)
end

Config.reload()

Enum.each(original_exporter_config, fn {key, value} ->
if value do
Application.put_env(:opentelemetry_exporter, key, value)
else
Application.delete_env(:opentelemetry_exporter, key)
end
end)
end)

:ok
end

describe "exporter_config/1" do
test "returns OTLP configuration with defaults" do
config = Setup.exporter_config()
Expand All @@ -29,6 +61,21 @@ defmodule Langfuse.OpenTelemetry.SetupTest do
expected_auth = "Basic " <> Base.encode64("pk-test:sk-test")
assert [{"Authorization", ^expected_auth}] = config[:otlp_headers]
end

test "uses configured cacertfile as exporter ssl_options" do
Application.put_env(:langfuse, :cacertfile, "/etc/ssl/langfuse-root-ca.pem")
Config.reload()

config = Setup.exporter_config()

assert config[:ssl_options] == [cacertfile: "/etc/ssl/langfuse-root-ca.pem"]
end

test "allows overriding cacertfile explicitly" do
config = Setup.exporter_config(cacertfile: "/tmp/custom-root-ca.pem")

assert config[:ssl_options] == [cacertfile: "/tmp/custom-root-ca.pem"]
end
end

describe "configure_exporter/1" do
Expand All @@ -47,6 +94,31 @@ defmodule Langfuse.OpenTelemetry.SetupTest do
headers = Application.get_env(:opentelemetry_exporter, :otlp_headers)
assert [{"Authorization", _}] = headers
end

test "sets ssl_options when cacertfile is configured" do
Setup.configure_exporter(
host: "https://test.langfuse.com",
public_key: "pk-test",
secret_key: "sk-test",
cacertfile: "/etc/ssl/langfuse-root-ca.pem"
)

assert Application.get_env(:opentelemetry_exporter, :ssl_options) == [
cacertfile: "/etc/ssl/langfuse-root-ca.pem"
]
end

test "clears stale ssl_options when no cacertfile is configured" do
Application.put_env(:opentelemetry_exporter, :ssl_options, cacertfile: "/tmp/stale.pem")

Setup.configure_exporter(
host: "https://test.langfuse.com",
public_key: "pk-test",
secret_key: "sk-test"
)

assert Application.get_env(:opentelemetry_exporter, :ssl_options) == nil
end
end

describe "sdk_config/1" do
Expand Down
Loading