Skip to content

Commit 9605098

Browse files
giortzisgclaude
andcommitted
feat: Add strict trace continuation support
Extract org_id from DSN host (e.g., o1234.ingest.sentry.io -> "1234") and propagate it as sentry-org_id in outgoing baggage headers. Validate incoming traces against the SDK's org_id to prevent cross-organization trace mixing. New configuration options: - :org_id - explicit org ID override for self-hosted/Relay setups - :strict_trace_continuation - when true, both org IDs must be present and match to continue a trace (default: false) Closes #1005 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e7eaaa8 commit 9605098

4 files changed

Lines changed: 350 additions & 13 deletions

File tree

lib/sentry/config.ex

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,29 @@ defmodule Sentry.Config do
433433
]
434434
]
435435
],
436+
org_id: [
437+
type: {:or, [:string, nil]},
438+
default: nil,
439+
type_doc: "`t:String.t/0` or `nil`",
440+
doc: """
441+
An explicit organization ID for trace continuation validation. If not set, the SDK
442+
will extract it from the DSN host (e.g., `o1234` from `o1234.ingest.sentry.io` gives `"1234"`).
443+
This is useful for self-hosted Sentry or Relay setups where the org ID cannot be extracted
444+
from the DSN. *Available since 12.1.0*.
445+
"""
446+
],
447+
strict_trace_continuation: [
448+
type: :boolean,
449+
default: false,
450+
doc: """
451+
When `true`, both the SDK's org ID and the incoming baggage `sentry-org_id` must be present
452+
and match for a trace to be continued. Traces with a missing org ID on either side are rejected
453+
and a new trace is started. When `false` (the default), only a mismatch between two present
454+
org IDs will cause a new trace to be started. See the
455+
[SDK spec](https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation)
456+
for the full decision matrix. *Available since 12.1.0*.
457+
"""
458+
],
436459
telemetry_processor_categories: [
437460
type: {:list, {:in, [:error, :check_in, :transaction, :log]}},
438461
default: [:log],
@@ -926,6 +949,29 @@ defmodule Sentry.Config do
926949
@spec transport_capacity() :: pos_integer()
927950
def transport_capacity, do: fetch!(:transport_capacity)
928951

952+
@spec org_id() :: String.t() | nil
953+
def org_id, do: get(:org_id)
954+
955+
@spec strict_trace_continuation?() :: boolean()
956+
def strict_trace_continuation?, do: fetch!(:strict_trace_continuation)
957+
958+
@doc """
959+
Returns the effective org ID, preferring the explicit `:org_id` config over the DSN-derived value.
960+
"""
961+
@spec effective_org_id() :: String.t() | nil
962+
def effective_org_id do
963+
case org_id() do
964+
nil ->
965+
case dsn() do
966+
%Sentry.DSN{org_id: org_id} -> org_id
967+
_ -> nil
968+
end
969+
970+
explicit ->
971+
explicit
972+
end
973+
end
974+
929975
@spec telemetry_processor_categories() :: [atom()]
930976
def telemetry_processor_categories, do: fetch!(:telemetry_processor_categories)
931977

lib/sentry/dsn.ex

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ defmodule Sentry.DSN do
55
original_dsn: String.t(),
66
endpoint_uri: String.t(),
77
public_key: String.t(),
8-
secret_key: String.t() | nil
8+
secret_key: String.t() | nil,
9+
org_id: String.t() | nil
910
}
1011

1112
defstruct [
1213
:original_dsn,
1314
:endpoint_uri,
1415
:public_key,
15-
:secret_key
16+
:secret_key,
17+
:org_id
1618
]
1719

1820
# {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}
@@ -65,7 +67,8 @@ defmodule Sentry.DSN do
6567
endpoint_uri: URI.to_string(endpoint_uri),
6668
public_key: public_key,
6769
secret_key: secret_key,
68-
original_dsn: dsn
70+
original_dsn: dsn,
71+
org_id: extract_org_id(uri.host)
6972
}
7073

7174
{:ok, parsed_dsn}
@@ -80,6 +83,16 @@ defmodule Sentry.DSN do
8083

8184
## Helpers
8285

86+
# Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123")
87+
defp extract_org_id(host) when is_binary(host) do
88+
case Regex.run(~r/^o(\d+)\./, host) do
89+
[_, org_id] -> org_id
90+
_ -> nil
91+
end
92+
end
93+
94+
defp extract_org_id(_host), do: nil
95+
8396
defp pop_project_id(uri_path) do
8497
path = String.split(uri_path, "/")
8598
{project_id, path} = List.pop_at(path, -1)

lib/sentry/opentelemetry/propagator.ex

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3535
carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier)
3636

3737
baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found)
38+
baggage_value = ensure_org_id_in_baggage(baggage_value)
3839

3940
if is_binary(baggage_value) and baggage_value != :not_found do
4041
setter.(@sentry_baggage_key, baggage_value, carrier)
@@ -56,19 +57,27 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
5657
header when is_binary(header) ->
5758
case decode_sentry_trace(header) do
5859
{:ok, {trace_hex, span_hex, sampled}} ->
59-
ctx =
60-
ctx
61-
|> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled})
62-
|> maybe_set_baggage(getter.(@sentry_baggage_key, carrier))
60+
raw_baggage = getter.(@sentry_baggage_key, carrier)
61+
62+
if should_continue_trace?(raw_baggage) do
63+
ctx =
64+
ctx
65+
|> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled})
66+
|> maybe_set_baggage(raw_baggage)
6367

64-
trace_id = hex_to_int(trace_hex)
65-
span_id = hex_to_int(span_hex)
68+
trace_id = hex_to_int(trace_hex)
69+
span_id = hex_to_int(span_hex)
6670

67-
# Create a remote, sampled parent span in the OTEL context.
68-
# We will set to "always sample" because Sentry will decide real sampling
69-
remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1)
71+
# Create a remote, sampled parent span in the OTEL context.
72+
# We will set to "always sample" because Sentry will decide real sampling
73+
remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1)
7074

71-
Tracer.set_current_span(ctx, remote_span_ctx)
75+
Tracer.set_current_span(ctx, remote_span_ctx)
76+
else
77+
require Logger
78+
Logger.debug("[Sentry] Not continuing trace due to org ID mismatch")
79+
ctx
80+
end
7281

7382
{:error, _reason} ->
7483
ctx
@@ -131,5 +140,65 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
131140
missing = total_bytes - byte_size(bin)
132141
if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin
133142
end
143+
144+
# Ensure sentry-org_id is present in the baggage string
145+
defp ensure_org_id_in_baggage(baggage) when is_binary(baggage) do
146+
org_id = Sentry.Config.effective_org_id()
147+
148+
if org_id != nil and not String.contains?(baggage, "sentry-org_id=") do
149+
baggage <> ",sentry-org_id=" <> org_id
150+
else
151+
baggage
152+
end
153+
end
154+
155+
defp ensure_org_id_in_baggage(_baggage) do
156+
case Sentry.Config.effective_org_id() do
157+
nil -> :not_found
158+
org_id -> "sentry-org_id=" <> org_id
159+
end
160+
end
161+
162+
# Extract sentry-org_id from a baggage header string
163+
defp extract_baggage_org_id(baggage) when is_binary(baggage) do
164+
baggage
165+
|> String.split(",")
166+
|> Enum.find_value(fn entry ->
167+
case String.split(String.trim(entry), "=", parts: 2) do
168+
["sentry-org_id", value] ->
169+
trimmed = String.trim(value)
170+
if trimmed == "", do: nil, else: trimmed
171+
172+
_ ->
173+
nil
174+
end
175+
end)
176+
end
177+
178+
defp extract_baggage_org_id(_), do: nil
179+
180+
# Determine whether to continue an incoming trace based on org_id validation
181+
@doc false
182+
def should_continue_trace?(raw_baggage) do
183+
sdk_org_id = Sentry.Config.effective_org_id()
184+
baggage_org_id = extract_baggage_org_id(raw_baggage)
185+
strict = Sentry.Config.strict_trace_continuation?()
186+
187+
cond do
188+
# Mismatched org IDs always reject
189+
sdk_org_id != nil and baggage_org_id != nil and sdk_org_id != baggage_org_id ->
190+
false
191+
192+
# In strict mode, both must be present and match (unless both are missing)
193+
strict and sdk_org_id == nil and baggage_org_id == nil ->
194+
true
195+
196+
strict ->
197+
sdk_org_id != nil and sdk_org_id == baggage_org_id
198+
199+
true ->
200+
true
201+
end
202+
end
134203
end
135204
end

0 commit comments

Comments
 (0)