diff --git a/CHANGELOG.md b/CHANGELOG.md index c938bae..0982930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/langfuse/client.ex b/lib/langfuse/client.ex index 95e7b8d..5a6888c 100644 --- a/lib/langfuse/client.ex +++ b/lib/langfuse/client.ex @@ -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 @@ -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} diff --git a/lib/langfuse/config.ex b/lib/langfuse/config.ex index fc72ae2..b4c651b 100644 --- a/lib/langfuse/config.ex +++ b/lib/langfuse/config.ex @@ -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 @@ -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 @@ -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" """ @@ -74,7 +78,8 @@ defmodule Langfuse.Config do :batch_size, :max_retries, :enabled, - :debug + :debug, + :cacertfile ] @typedoc """ @@ -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 @@ -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 diff --git a/lib/langfuse/http.ex b/lib/langfuse/http.ex index b1cacbd..d18fc8f 100644 --- a/lib/langfuse/http.ex +++ b/lib/langfuse/http.ex @@ -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() @@ -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, diff --git a/lib/langfuse/open_telemetry/setup.ex b/lib/langfuse/open_telemetry/setup.ex index 699195c..fb68cb2 100644 --- a/lib/langfuse/open_telemetry/setup.ex +++ b/lib/langfuse/open_telemetry/setup.ex @@ -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 @@ -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() @@ -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}") @@ -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 """ @@ -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 @@ -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 @@ -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 diff --git a/test/langfuse/config_test.exs b/test/langfuse/config_test.exs index bc213c6..eaa39f6 100644 --- a/test/langfuse/config_test.exs +++ b/test/langfuse/config_test.exs @@ -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 -> @@ -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 diff --git a/test/langfuse/http_test.exs b/test/langfuse/http_test.exs index a29b150..08fc129 100644 --- a/test/langfuse/http_test.exs +++ b/test/langfuse/http_test.exs @@ -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 diff --git a/test/langfuse/ingestion_test.exs b/test/langfuse/ingestion_test.exs index 9f2eb6a..5f96849 100644 --- a/test/langfuse/ingestion_test.exs +++ b/test/langfuse/ingestion_test.exs @@ -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} -> @@ -34,6 +35,7 @@ defmodule Langfuse.IngestionTest do end) Langfuse.Config.reload() + capture_log(fn -> Ingestion.flush() end) end) :ok diff --git a/test/langfuse/open_telemetry/setup_test.exs b/test/langfuse/open_telemetry/setup_test.exs index e91e526..f04cd08 100644 --- a/test/langfuse/open_telemetry/setup_test.exs +++ b/test/langfuse/open_telemetry/setup_test.exs @@ -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() @@ -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 @@ -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