Skip to content

Commit 48b9c5c

Browse files
giortzisgclaudesolnic
authored
feat: Add strict trace continuation support (#1016)
* 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> * fix: Import TestHelpers in strict trace continuation test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add strict trace continuation tests for baggage propagation * fix: enhance logging for trace continuation org ID mismatch * fix: add custom validation for org_id in config * fix: improve logging for trace continuation org ID validation * fix: prevent leading comma in baggage when injecting empty string * refactor: simplify tests * fix(tests): formatting * refactor(e2e): improve env var handling in Playwright test suite - Add `requireEnv()` helper in playwright.config.ts that throws when a required env var is missing to make configuration errors explicit - Use `??=` to set local dev defaults (localhost ports) before validation so `npx playwright test` works out of the box without env vars - Hoist PHOENIX_URL to file scope in tracing.spec.ts with an explicit throw, removing the per-describe fallback declarations - Replace the hardcoded 'http://localhost:4000' URL inside page.evaluate with the file-scoped PHOENIX_URL constant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(e2e): add strict trace continuation integration specs Add three end-to-end Playwright specs covering the strict trace continuation feature: 1. Matching org_id in baggage → incoming trace_id is preserved 2. Mismatched org_id in baggage → new trace_id is started 3. No org_id in baggage with strict=false → incoming trace_id is preserved Supporting changes: - Pass SENTRY_ORG_ID=123 via the webServer command in playwright.config.ts so the Phoenix app starts with an org ID configured - Read SENTRY_ORG_ID and SENTRY_STRICT_TRACE env vars in phoenix_app/config/runtime.exs when SENTRY_E2E_TEST_MODE=true Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(e2e): add strict trace continuation spec for strict=true with no org_id Adds a 4th E2E spec covering the strict mode path where baggage carries no sentry-org_id: with strict_trace_continuation=true a new trace must be started rather than continuing the incoming one. To enable per-test config toggling without a second server, adds a PUT /sentry-test-config endpoint to the Phoenix app that calls Sentry.put_config/2 for an allowlist of keys. A test.afterEach hook resets strict_trace_continuation back to false after every test so the setting cannot bleed between specs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ci): pass SENTRY_ORG_ID=123 when starting Phoenix server in e2e workflow The strict trace continuation specs require the Phoenix app to have an org ID configured so that mismatched/missing org IDs in incoming baggage are correctly rejected. The webServer block in playwright.config.ts already passes SENTRY_ORG_ID=123, but when SENTRY_E2E_SERVERS_RUNNING=true that block is skipped and the server is started directly by CI - without the env var, causing all rejection-path specs to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: strip empty sentry-org_id entries before appending SDK org_id to baggage When incoming baggage contains a sentry-org_id= entry with an empty value, extract_baggage_org_id/1 correctly returns nil (treating it as absent). However, ensure_org_id_in_baggage/1 then appended a second sentry-org_id=<sdk_value> entry, producing a duplicate key. A W3C baggage parser on a downstream service would pick up the first (empty) entry and ignore the valid one. Fix by stripping any empty-valued sentry-org_id entries before appending the SDK org ID, avoiding duplicate keys entirely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Peter Solnica <peter@solnica.online>
1 parent cd5c019 commit 48b9c5c

12 files changed

Lines changed: 856 additions & 29 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jobs:
7777
working-directory: test_integrations/phoenix_app
7878
run: |
7979
rm -f tmp/sentry_debug_events.log
80-
SENTRY_E2E_TEST_MODE=true mix phx.server &
80+
SENTRY_E2E_TEST_MODE=true SENTRY_ORG_ID=123 mix phx.server &
8181
echo $! > /tmp/phoenix.pid
8282
echo "Phoenix server started with PID $(cat /tmp/phoenix.pid)"
8383

lib/sentry/config.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,29 @@ defmodule Sentry.Config do
444444
]
445445
]
446446
],
447+
org_id: [
448+
type: {:custom, __MODULE__, :__validate_org_id__, []},
449+
default: nil,
450+
type_doc: "`t:String.t/0` or `nil`",
451+
doc: """
452+
An explicit organization ID for trace continuation validation. If not set, the SDK
453+
will extract it from the DSN host (e.g., `o1234` from `o1234.ingest.sentry.io` gives `"1234"`).
454+
This is useful for self-hosted Sentry or Relay setups where the org ID cannot be extracted
455+
from the DSN. *Available since 12.1.0*.
456+
"""
457+
],
458+
strict_trace_continuation: [
459+
type: :boolean,
460+
default: false,
461+
doc: """
462+
When `true`, both the SDK's org ID and the incoming baggage `sentry-org_id` must be present
463+
and match for a trace to be continued. Traces with a missing org ID on either side are rejected
464+
and a new trace is started. When `false` (the default), only a mismatch between two present
465+
org IDs will cause a new trace to be started. See the
466+
[SDK spec](https://develop.sentry.dev/sdk/foundations/trace-propagation/#strict-trace-continuation)
467+
for the full decision matrix. *Available since 12.1.0*.
468+
"""
469+
],
447470
telemetry_processor_categories: [
448471
type: {:list, {:in, [:error, :check_in, :transaction, :log]}},
449472
default: [],
@@ -972,6 +995,29 @@ defmodule Sentry.Config do
972995
@spec transport_capacity() :: pos_integer()
973996
def transport_capacity, do: fetch!(:transport_capacity)
974997

998+
@spec org_id() :: String.t() | nil
999+
def org_id, do: get(:org_id)
1000+
1001+
@spec strict_trace_continuation?() :: boolean()
1002+
def strict_trace_continuation?, do: fetch!(:strict_trace_continuation)
1003+
1004+
@doc """
1005+
Returns the effective org ID, preferring the explicit `:org_id` config over the DSN-derived value.
1006+
"""
1007+
@spec effective_org_id() :: String.t() | nil
1008+
def effective_org_id do
1009+
case org_id() do
1010+
nil ->
1011+
case dsn() do
1012+
%Sentry.DSN{org_id: org_id} -> org_id
1013+
_ -> nil
1014+
end
1015+
1016+
explicit ->
1017+
explicit
1018+
end
1019+
end
1020+
9751021
@spec telemetry_processor_categories() :: [atom()]
9761022
def telemetry_processor_categories, do: fetch!(:telemetry_processor_categories)
9771023

@@ -1279,4 +1325,18 @@ defmodule Sentry.Config do
12791325
def __validate_namespace__(other) do
12801326
{:error, "expected :namespace to be a {module, function} tuple, got: #{inspect(other)}"}
12811327
end
1328+
1329+
def __validate_org_id__(nil), do: {:ok, nil}
1330+
1331+
def __validate_org_id__(value) when is_binary(value) and value != "" do
1332+
{:ok, value}
1333+
end
1334+
1335+
def __validate_org_id__("") do
1336+
{:error, "expected :org_id to be a non-empty string or nil, got empty string"}
1337+
end
1338+
1339+
def __validate_org_id__(other) do
1340+
{:error, "expected :org_id to be a non-empty string or nil, got: #{inspect(other)}"}
1341+
end
12821342
end

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: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
1010
import Bitwise
1111

1212
require Record
13+
require Logger
1314
require OpenTelemetry.Tracer, as: Tracer
1415

1516
@behaviour :otel_propagator_text_map
@@ -35,6 +36,7 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
3536
carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier)
3637

3738
baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found)
39+
baggage_value = ensure_org_id_in_baggage(baggage_value)
3840

3941
if is_binary(baggage_value) and baggage_value != :not_found do
4042
setter.(@sentry_baggage_key, baggage_value, carrier)
@@ -56,19 +58,39 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
5658
header when is_binary(header) ->
5759
case decode_sentry_trace(header) do
5860
{: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))
61+
raw_baggage = getter.(@sentry_baggage_key, carrier)
62+
63+
if should_continue_trace?(raw_baggage) do
64+
ctx =
65+
ctx
66+
|> :otel_ctx.set_value(@sentry_trace_ctx_key, {trace_hex, span_hex, sampled})
67+
|> maybe_set_baggage(raw_baggage)
68+
69+
trace_id = hex_to_int(trace_hex)
70+
span_id = hex_to_int(span_hex)
6371

64-
trace_id = hex_to_int(trace_hex)
65-
span_id = hex_to_int(span_hex)
72+
# Create a remote, sampled parent span in the OTEL context.
73+
# We will set to "always sample" because Sentry will decide real sampling
74+
remote_span_ctx = :otel_tracer.from_remote_span(trace_id, span_id, 1)
6675

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)
76+
Tracer.set_current_span(ctx, remote_span_ctx)
77+
else
78+
sdk_org_id = Sentry.Config.effective_org_id()
79+
baggage_org_id = extract_baggage_org_id(raw_baggage)
7080

71-
Tracer.set_current_span(ctx, remote_span_ctx)
81+
reason =
82+
if sdk_org_id != nil and baggage_org_id != nil do
83+
"org ID mismatch"
84+
else
85+
"org ID missing (strict mode)"
86+
end
87+
88+
Logger.warning(
89+
"[Sentry] Not continuing trace: #{reason} (sdk: #{inspect(sdk_org_id)}, incoming: #{inspect(baggage_org_id)})"
90+
)
91+
92+
ctx
93+
end
7294

7395
{:error, _reason} ->
7496
ctx
@@ -131,5 +153,82 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
131153
missing = total_bytes - byte_size(bin)
132154
if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin
133155
end
156+
157+
# Ensure sentry-org_id is present in the baggage string
158+
defp ensure_org_id_in_baggage(baggage) when is_binary(baggage) do
159+
org_id = Sentry.Config.effective_org_id()
160+
161+
if org_id != nil and extract_baggage_org_id(baggage) == nil do
162+
# Strip any existing sentry-org_id entries with empty values before appending
163+
# to avoid producing duplicate keys (e.g. "sentry-org_id=,sentry-org_id=99").
164+
stripped =
165+
baggage
166+
|> String.split(",")
167+
|> Enum.reject(fn entry ->
168+
case String.split(String.trim(entry), "=", parts: 2) do
169+
["sentry-org_id", value] -> String.trim(value) == ""
170+
_ -> false
171+
end
172+
end)
173+
|> Enum.join(",")
174+
175+
if stripped == "" do
176+
"sentry-org_id=" <> org_id
177+
else
178+
stripped <> ",sentry-org_id=" <> org_id
179+
end
180+
else
181+
baggage
182+
end
183+
end
184+
185+
defp ensure_org_id_in_baggage(_baggage) do
186+
case Sentry.Config.effective_org_id() do
187+
nil -> :not_found
188+
org_id -> "sentry-org_id=" <> org_id
189+
end
190+
end
191+
192+
# Extract sentry-org_id from a baggage header string
193+
defp extract_baggage_org_id(baggage) when is_binary(baggage) do
194+
baggage
195+
|> String.split(",")
196+
|> Enum.find_value(fn entry ->
197+
case String.split(String.trim(entry), "=", parts: 2) do
198+
["sentry-org_id", value] ->
199+
trimmed = String.trim(value)
200+
if trimmed == "", do: nil, else: trimmed
201+
202+
_ ->
203+
nil
204+
end
205+
end)
206+
end
207+
208+
defp extract_baggage_org_id(_), do: nil
209+
210+
# Determine whether to continue an incoming trace based on org_id validation
211+
@doc false
212+
def should_continue_trace?(raw_baggage) do
213+
sdk_org_id = Sentry.Config.effective_org_id()
214+
baggage_org_id = extract_baggage_org_id(raw_baggage)
215+
strict = Sentry.Config.strict_trace_continuation?()
216+
217+
cond do
218+
# Mismatched org IDs always reject
219+
sdk_org_id != nil and baggage_org_id != nil and sdk_org_id != baggage_org_id ->
220+
false
221+
222+
# In strict mode, both must be present and match (unless both are missing)
223+
strict and sdk_org_id == nil and baggage_org_id == nil ->
224+
true
225+
226+
strict ->
227+
sdk_org_id != nil and sdk_org_id == baggage_org_id
228+
229+
true ->
230+
true
231+
end
232+
end
134233
end
135234
end

test/sentry/config_test.exs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Sentry.ConfigTest do
22
use Sentry.Case, async: false
33

4+
import Sentry.TestHelpers
5+
46
alias Sentry.Config
57

68
describe "validate!/0" do
@@ -429,4 +431,67 @@ defmodule Sentry.ConfigTest do
429431
assert config[:before_send_metric] == nil
430432
end
431433
end
434+
435+
describe ":org_id" do
436+
test "defaults to nil" do
437+
assert Config.validate!([])[:org_id] == nil
438+
end
439+
440+
test "accepts a non-empty string" do
441+
assert Config.validate!(org_id: "1234567")[:org_id] == "1234567"
442+
end
443+
444+
test "accepts nil explicitly" do
445+
assert Config.validate!(org_id: nil)[:org_id] == nil
446+
end
447+
448+
test "rejects an empty string" do
449+
assert_raise ArgumentError, ~r/expected :org_id to be a non-empty string or nil/, fn ->
450+
Config.validate!(org_id: "")
451+
end
452+
end
453+
454+
test "rejects a non-string value" do
455+
assert_raise ArgumentError, ~r/invalid value for :org_id option/, fn ->
456+
Config.validate!(org_id: 1234)
457+
end
458+
end
459+
end
460+
461+
describe "effective_org_id/0" do
462+
test "returns nil when no org_id is configured and DSN has no org ID" do
463+
put_test_config(dsn: "https://public:secret@app.getsentry.com/1", org_id: nil)
464+
assert Config.effective_org_id() == nil
465+
end
466+
467+
test "returns explicit org_id when configured" do
468+
put_test_config(org_id: "9876543")
469+
assert Config.effective_org_id() == "9876543"
470+
end
471+
472+
test "falls back to org ID extracted from DSN host" do
473+
put_test_config(dsn: "https://public@o1234567.ingest.sentry.io/123", org_id: nil)
474+
assert Config.effective_org_id() == "1234567"
475+
end
476+
477+
test "explicit org_id takes precedence over DSN-derived org ID" do
478+
put_test_config(dsn: "https://public@o1234567.ingest.sentry.io/123", org_id: "9999999")
479+
assert Config.effective_org_id() == "9999999"
480+
end
481+
end
482+
483+
describe ":strict_trace_continuation" do
484+
test "defaults to false" do
485+
assert Config.validate!([])[:strict_trace_continuation] == false
486+
end
487+
488+
test "accepts true" do
489+
assert Config.validate!(strict_trace_continuation: true)[:strict_trace_continuation] == true
490+
end
491+
492+
test "accepts false" do
493+
assert Config.validate!(strict_trace_continuation: false)[:strict_trace_continuation] ==
494+
false
495+
end
496+
end
432497
end

0 commit comments

Comments
 (0)