diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 85126696..9216efc8 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -433,6 +433,29 @@ defmodule Sentry.Config do ] ] ], + org_id: [ + type: {:or, [:string, nil]}, + default: nil, + type_doc: "`t:String.t/0` or `nil`", + doc: """ + An explicit organization ID for trace continuation validation. If not set, the SDK + will extract it from the DSN host (e.g., `o1234` from `o1234.ingest.sentry.io` gives `"1234"`). + This is useful for self-hosted Sentry or Relay setups where the org ID cannot be extracted + from the DSN. *Available since 12.1.0*. + """ + ], + strict_trace_continuation: [ + type: :boolean, + default: false, + doc: """ + When `true`, both the SDK's org ID and the incoming baggage `sentry-org_id` must be present + and match for a trace to be continued. Traces with a missing org ID on either side are rejected + and a new trace is started. When `false` (the default), only a mismatch between two present + org IDs will cause a new trace to be started. See the + [SDK spec](https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation) + for the full decision matrix. *Available since 12.1.0*. + """ + ], telemetry_processor_categories: [ type: {:list, {:in, [:error, :check_in, :transaction, :log]}}, default: [:log], @@ -926,6 +949,29 @@ defmodule Sentry.Config do @spec transport_capacity() :: pos_integer() def transport_capacity, do: fetch!(:transport_capacity) + @spec org_id() :: String.t() | nil + def org_id, do: get(:org_id) + + @spec strict_trace_continuation?() :: boolean() + def strict_trace_continuation?, do: fetch!(:strict_trace_continuation) + + @doc """ + Returns the effective org ID, preferring the explicit `:org_id` config over the DSN-derived value. + """ + @spec effective_org_id() :: String.t() | nil + def effective_org_id do + case org_id() do + nil -> + case dsn() do + %Sentry.DSN{org_id: org_id} -> org_id + _ -> nil + end + + explicit -> + explicit + end + end + @spec telemetry_processor_categories() :: [atom()] def telemetry_processor_categories, do: fetch!(:telemetry_processor_categories) diff --git a/lib/sentry/dsn.ex b/lib/sentry/dsn.ex index 93704244..3d6822ae 100644 --- a/lib/sentry/dsn.ex +++ b/lib/sentry/dsn.ex @@ -5,14 +5,16 @@ defmodule Sentry.DSN do original_dsn: String.t(), endpoint_uri: String.t(), public_key: String.t(), - secret_key: String.t() | nil + secret_key: String.t() | nil, + org_id: String.t() | nil } defstruct [ :original_dsn, :endpoint_uri, :public_key, - :secret_key + :secret_key, + :org_id ] # {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID} @@ -65,7 +67,8 @@ defmodule Sentry.DSN do endpoint_uri: URI.to_string(endpoint_uri), public_key: public_key, secret_key: secret_key, - original_dsn: dsn + original_dsn: dsn, + org_id: extract_org_id(uri.host) } {:ok, parsed_dsn} @@ -80,6 +83,16 @@ defmodule Sentry.DSN do ## Helpers + # Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123") + defp extract_org_id(host) when is_binary(host) do + case Regex.run(~r/^o(\d+)\./, host) do + [_, org_id] -> org_id + _ -> nil + end + end + + defp extract_org_id(_host), do: nil + defp pop_project_id(uri_path) do path = String.split(uri_path, "/") {project_id, path} = List.pop_at(path, -1) diff --git a/lib/sentry/opentelemetry/propagator.ex b/lib/sentry/opentelemetry/propagator.ex index cb3d73fe..7781d2bc 100644 --- a/lib/sentry/opentelemetry/propagator.ex +++ b/lib/sentry/opentelemetry/propagator.ex @@ -35,6 +35,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier) baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found) + baggage_value = ensure_org_id_in_baggage(baggage_value) if is_binary(baggage_value) and baggage_value != :not_found do setter.(@sentry_baggage_key, baggage_value, carrier) @@ -56,19 +57,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do header when is_binary(header) -> case decode_sentry_trace(header) do {:ok, {trace_hex, span_hex, sampled}} -> - ctx = - ctx - |> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled}) - |> maybe_set_baggage(getter.(@sentry_baggage_key, carrier)) + raw_baggage = getter.(@sentry_baggage_key, carrier) + + if should_continue_trace?(raw_baggage) do + ctx = + ctx + |> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled}) + |> maybe_set_baggage(raw_baggage) - trace_id = hex_to_int(trace_hex) - span_id = hex_to_int(span_hex) + trace_id = hex_to_int(trace_hex) + span_id = hex_to_int(span_hex) - # Create a remote, sampled parent span in the OTEL context. - # We will set to "always sample" because Sentry will decide real sampling - remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1) + # Create a remote, sampled parent span in the OTEL context. + # We will set to "always sample" because Sentry will decide real sampling + remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1) - Tracer.set_current_span(ctx, remote_span_ctx) + Tracer.set_current_span(ctx, remote_span_ctx) + else + require Logger + Logger.debug("[Sentry] Not continuing trace due to org ID mismatch") + ctx + end {:error, _reason} -> ctx @@ -131,5 +140,65 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do missing = total_bytes - byte_size(bin) if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin end + + # Ensure sentry-org_id is present in the baggage string + defp ensure_org_id_in_baggage(baggage) when is_binary(baggage) do + org_id = Sentry.Config.effective_org_id() + + if org_id != nil and not String.contains?(baggage, "sentry-org_id=") do + baggage <> ",sentry-org_id=" <> org_id + else + baggage + end + end + + defp ensure_org_id_in_baggage(_baggage) do + case Sentry.Config.effective_org_id() do + nil -> :not_found + org_id -> "sentry-org_id=" <> org_id + end + end + + # Extract sentry-org_id from a baggage header string + defp extract_baggage_org_id(baggage) when is_binary(baggage) do + baggage + |> String.split(",") + |> Enum.find_value(fn entry -> + case String.split(String.trim(entry), "=", parts: 2) do + ["sentry-org_id", value] -> + trimmed = String.trim(value) + if trimmed == "", do: nil, else: trimmed + + _ -> + nil + end + end) + end + + defp extract_baggage_org_id(_), do: nil + + # Determine whether to continue an incoming trace based on org_id validation + @doc false + def should_continue_trace?(raw_baggage) do + sdk_org_id = Sentry.Config.effective_org_id() + baggage_org_id = extract_baggage_org_id(raw_baggage) + strict = Sentry.Config.strict_trace_continuation?() + + cond do + # Mismatched org IDs always reject + sdk_org_id != nil and baggage_org_id != nil and sdk_org_id != baggage_org_id -> + false + + # In strict mode, both must be present and match (unless both are missing) + strict and sdk_org_id == nil and baggage_org_id == nil -> + true + + strict -> + sdk_org_id != nil and sdk_org_id == baggage_org_id + + true -> + true + end + end end end diff --git a/test/sentry/strict_trace_continuation_test.exs b/test/sentry/strict_trace_continuation_test.exs new file mode 100644 index 00000000..8caa60d1 --- /dev/null +++ b/test/sentry/strict_trace_continuation_test.exs @@ -0,0 +1,211 @@ +defmodule Sentry.StrictTraceContinuationTest do + use Sentry.Case, async: false + + import Sentry.TestHelpers + + alias Sentry.Config + + describe "DSN org_id extraction" do + test "extracts org_id from standard DSN host" do + {:ok, dsn} = Sentry.DSN.parse("https://key@o1234.ingest.sentry.io/123") + assert dsn.org_id == "1234" + end + + test "extracts org_id from DSN with US region" do + {:ok, dsn} = Sentry.DSN.parse("https://key@o42.ingest.us.sentry.io/123") + assert dsn.org_id == "42" + end + + test "returns nil for DSN without org_id" do + {:ok, dsn} = Sentry.DSN.parse("https://key@sentry.io/123") + assert dsn.org_id == nil + end + + test "returns nil for self-hosted DSN" do + {:ok, dsn} = Sentry.DSN.parse("https://key@my-sentry.example.com/123") + assert dsn.org_id == nil + end + + test "returns nil for localhost DSN" do + {:ok, dsn} = Sentry.DSN.parse("http://key@localhost:9000/123") + assert dsn.org_id == nil + end + end + + describe "Config options" do + test ":org_id defaults to nil" do + assert Config.org_id() == nil + end + + test ":strict_trace_continuation defaults to false" do + assert Config.strict_trace_continuation?() == false + end + + test ":org_id can be set" do + put_test_config(org_id: "999") + assert Config.org_id() == "999" + end + + test ":strict_trace_continuation can be set" do + put_test_config(strict_trace_continuation: true) + assert Config.strict_trace_continuation?() == true + end + end + + describe "effective_org_id/0" do + test "returns nil when no org_id configured and DSN has no org" do + put_test_config(dsn: "https://key@sentry.io/123") + assert Config.effective_org_id() == nil + end + + test "returns DSN-derived org_id" do + put_test_config(dsn: "https://key@o123.ingest.sentry.io/456") + assert Config.effective_org_id() == "123" + end + + test "explicit org_id takes precedence over DSN" do + put_test_config(dsn: "https://key@o123.ingest.sentry.io/456", org_id: "999") + assert Config.effective_org_id() == "999" + end + + test "returns nil when DSN is nil" do + put_test_config(dsn: nil) + assert Config.effective_org_id() == nil + end + end + + if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + describe "should_continue_trace?/1" do + # Decision matrix tests + # | Baggage org | SDK org | strict=false | strict=true | + # |-------------|---------|-------------|-------------| + # | 1 | 1 | Continue | Continue | + # | None | 1 | Continue | New trace | + # | 1 | None | Continue | New trace | + # | None | None | Continue | Continue | + # | 1 | 2 | New trace | New trace | + + test "strict=false, matching orgs - continues trace" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: false + ) + + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "strict=false, baggage missing org - continues trace" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: false + ) + + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?("sentry-trace_id=abc") + end + + test "strict=false, SDK missing org - continues trace" do + put_test_config(dsn: "https://key@sentry.io/123", strict_trace_continuation: false) + + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "strict=false, both missing org - continues trace" do + put_test_config(dsn: "https://key@sentry.io/123", strict_trace_continuation: false) + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?("sentry-trace_id=abc") + end + + test "strict=false, mismatched orgs - starts new trace" do + put_test_config( + dsn: "https://key@o2.ingest.sentry.io/123", + strict_trace_continuation: false + ) + + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "strict=true, matching orgs - continues trace" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: true + ) + + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "strict=true, baggage missing org - starts new trace" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: true + ) + + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?("sentry-trace_id=abc") + end + + test "strict=true, SDK missing org - starts new trace" do + put_test_config(dsn: "https://key@sentry.io/123", strict_trace_continuation: true) + + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "strict=true, both missing org - continues trace" do + put_test_config(dsn: "https://key@sentry.io/123", strict_trace_continuation: true) + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?("sentry-trace_id=abc") + end + + test "strict=true, mismatched orgs - starts new trace" do + put_test_config( + dsn: "https://key@o2.ingest.sentry.io/123", + strict_trace_continuation: true + ) + + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "explicit org_id overrides DSN for validation" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + org_id: "2", + strict_trace_continuation: false + ) + + # SDK org is "2" (explicit), baggage org is "1" -> mismatch + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-trace_id=abc,sentry-org_id=1" + ) + end + + test "handles nil baggage" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: false + ) + + assert Sentry.OpenTelemetry.Propagator.should_continue_trace?(nil) + end + + test "handles empty baggage org_id value" do + put_test_config( + dsn: "https://key@o1.ingest.sentry.io/123", + strict_trace_continuation: true + ) + + # Empty org_id in baggage should be treated as missing + refute Sentry.OpenTelemetry.Propagator.should_continue_trace?( + "sentry-org_id=,sentry-trace_id=abc" + ) + end + end + end +end