Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
46 changes: 46 additions & 0 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,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: [],
Expand Down Expand Up @@ -972,6 +995,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)

Expand Down
19 changes: 16 additions & 3 deletions lib/sentry/dsn.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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)
Expand Down
95 changes: 85 additions & 10 deletions lib/sentry/opentelemetry/propagator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
import Bitwise

require Record
require Logger
require OpenTelemetry.Tracer, as: Tracer

@behaviour :otel_propagator_text_map
Expand All @@ -35,6 +36,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)
Expand All @@ -56,19 +58,32 @@ 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)
else
sdk_org_id = Sentry.Config.effective_org_id()
baggage_org_id = extract_baggage_org_id(raw_baggage)

Tracer.set_current_span(ctx, remote_span_ctx)
Logger.warning(
"[Sentry] Not continuing trace due to org ID mismatch (sdk: #{sdk_org_id}, incoming: #{baggage_org_id})"
)

ctx
end

{:error, _reason} ->
ctx
Expand Down Expand Up @@ -131,5 +146,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 extract_baggage_org_id(baggage) == nil 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
103 changes: 103 additions & 0 deletions test/sentry/opentelemetry/propagator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ defmodule Sentry.OpenTelemetry.PropagatorTest do

describe "baggage propagation" do
test "injects baggage from context" do
put_test_config(dsn: "http://public:secret@localhost:9000/1")

trace_id = 0x1234567890ABCDEF1234567890ABCDEF
span_id = 0x1234567890ABCDEF
trace_flags = 1
Expand Down Expand Up @@ -210,6 +212,8 @@ defmodule Sentry.OpenTelemetry.PropagatorTest do
end

test "does not inject baggage when not in context" do
put_test_config(dsn: "http://public:secret@localhost:9000/1")

trace_id = 0x1234567890ABCDEF1234567890ABCDEF
span_id = 0x1234567890ABCDEF
trace_flags = 1
Expand Down Expand Up @@ -269,5 +273,104 @@ defmodule Sentry.OpenTelemetry.PropagatorTest do
end
end
end

describe "strict trace continuation integration" do
test "org IDs match: trace is continued end-to-end" do
put_test_config(
dsn: "https://key@o99.ingest.sentry.io/123",
strict_trace_continuation: false
)

sentry_trace = "1234567890abcdef1234567890abcdef-1234567890abcdef-1"
baggage = "sentry-org_id=99,sentry-public_key=key"

getter = fn
"sentry-trace", _ -> sentry_trace
"baggage", _ -> baggage
_, _ -> :undefined
end

ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, [])

assert Tracer.current_span_ctx(ctx) != :undefined
end

test "org ID mismatch: trace is NOT continued and a fresh context is returned" do
put_test_config(
dsn: "https://key@o99.ingest.sentry.io/123",
strict_trace_continuation: false
)

sentry_trace = "1234567890abcdef1234567890abcdef-1234567890abcdef-1"
baggage = "sentry-org_id=42,sentry-public_key=key"

getter = fn
"sentry-trace", _ -> sentry_trace
"baggage", _ -> baggage
_, _ -> :undefined
end

ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, [])

assert Tracer.current_span_ctx(ctx) == :undefined
end

test "strict=true, baggage missing org ID: trace is NOT continued" do
put_test_config(
dsn: "https://key@o99.ingest.sentry.io/123",
strict_trace_continuation: true
)

sentry_trace = "1234567890abcdef1234567890abcdef-1234567890abcdef-1"
baggage = "sentry-public_key=key"

getter = fn
"sentry-trace", _ -> sentry_trace
"baggage", _ -> baggage
_, _ -> :undefined
end

ctx = Propagator.extract(:otel_ctx.new(), %{}, nil, getter, [])

assert Tracer.current_span_ctx(ctx) == :undefined
end

test "inject adds sentry-org_id to outgoing baggage when SDK org is configured" do
put_test_config(dsn: "https://key@o99.ingest.sentry.io/123")

Tracer.with_span "test_span" do
baggage_value = "sentry-trace_id=abc,sentry-public_key=key"

ctx =
:otel_ctx.get_current()
|> :otel_ctx.set_value(:"sentry-baggage", baggage_value)

setter = fn key, value, carrier -> Map.put(carrier, key, value) end
carrier = Propagator.inject(ctx, %{}, setter, [])

assert String.contains?(Map.get(carrier, "baggage", ""), "sentry-org_id=99")
end
end

test "inject does not duplicate sentry-org_id when already present in baggage" do
put_test_config(dsn: "https://key@o99.ingest.sentry.io/123")

Tracer.with_span "test_span" do
baggage_value = "sentry-trace_id=abc,sentry-org_id=99"

ctx =
:otel_ctx.get_current()
|> :otel_ctx.set_value(:"sentry-baggage", baggage_value)

setter = fn key, value, carrier -> Map.put(carrier, key, value) end
carrier = Propagator.inject(ctx, %{}, setter, [])

injected_baggage = Map.get(carrier, "baggage", "")

assert String.contains?(injected_baggage, "sentry-org_id=99")
assert length(String.split(injected_baggage, "sentry-org_id=")) == 2
end
end
end
end
end
Loading
Loading