diff --git a/lib/sentry/event.ex b/lib/sentry/event.ex index 08243740..ff816973 100644 --- a/lib/sentry/event.ex +++ b/lib/sentry/event.ex @@ -161,6 +161,16 @@ defmodule Sentry.Event do > See also `Sentry.Context` for information on the Sentry context and `Sentry` for > information on configuration. + > #### Trace Context {: .info} + > + > When the calling process has an active OpenTelemetry span, the event gets a + > `contexts.trace` with the `trace_id` and `span_id` of that span, so that Sentry + > links the error to the trace it happened in. The key is omitted when there is no + > active span or when OpenTelemetry is not available. + > + > The span is read from the process that creates the event, so errors reported from + > a process that did not inherit the OpenTelemetry context are not linked to a trace. + ## Options #{NimbleOptions.docs(@create_event_opts_schema)} @@ -485,10 +495,41 @@ defmodule Sentry.Event do version_string -> version_string end - %{ + contexts = %{ os: %{name: Atom.to_string(os_name), version: os_version}, runtime: %{name: "elixir", version: System.build_info().build} } + + case current_trace_context() do + nil -> contexts + trace -> Map.put(contexts, :trace, trace) + end + end + + if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do + defp current_trace_context do + case :otel_tracer.current_span_ctx() do + :undefined -> + nil + + span_ctx -> + trace_id = :otel_span.trace_id(span_ctx) + span_id = :otel_span.span_id(span_ctx) + + if trace_id != 0 and span_id != 0 do + %{trace_id: format_id(trace_id, 32), span_id: format_id(span_id, 16)} + end + end + end + + defp format_id(id, length) do + id + |> Integer.to_string(16) + |> String.pad_leading(length, "0") + |> String.downcase() + end + else + defp current_trace_context, do: nil end # Used to compare events for deduplication. See "Sentry.Dedupe". diff --git a/test/sentry_test.exs b/test/sentry_test.exs index a54498b5..6148deae 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -1,6 +1,8 @@ defmodule SentryTest do use Sentry.Case + require OpenTelemetry.Tracer, as: Tracer + import ExUnit.CaptureLog import Sentry.Test.Assertions import Sentry.TestHelpers @@ -394,6 +396,56 @@ defmodule SentryTest do end end + describe "trace context on captured errors without tracing" do + test "sends the error without a trace context when nothing is being traced", %{bypass: bypass} do + ref = SentryTest.setup_bypass_envelope_collector(bypass, type: "event") + + assert {:ok, _} = Sentry.capture_message("standalone failure", result: :sync) + + assert [event] = extract_events(collect_envelopes(ref, 1)) + refute event["contexts"]["trace"] + end + end + + describe "trace context on captured errors" do + setup %{bypass: bypass} do + put_test_config(traces_sample_rate: 1.0) + %{ref: SentryTest.setup_bypass_envelope_collector(bypass)} + end + + test "sends the error and the transaction of the same operation with the same trace_id", %{ + ref: ref + } do + Tracer.with_span "checkout" do + assert {:ok, _} = Sentry.capture_message("checkout failed", result: :sync) + end + + envelopes = collect_envelopes(ref, 2) + + assert [event] = extract_events(envelopes) + assert [transaction] = extract_transactions(envelopes) + + assert event["contexts"]["trace"]["trace_id"] == + transaction["contexts"]["trace"]["trace_id"] + end + + test "points the error at the span it was captured in", %{ref: ref} do + Tracer.with_span "checkout" do + Tracer.with_span "charge_card" do + assert {:ok, _} = Sentry.capture_message("charge failed", result: :sync) + end + end + + envelopes = collect_envelopes(ref, 2) + + assert [event] = extract_events(envelopes) + assert [transaction] = extract_transactions(envelopes) + assert [child_span] = transaction["spans"] + + assert event["contexts"]["trace"]["span_id"] == child_span["span_id"] + end + end + describe "flush/1" do test "warns and returns :ok when the TelemetryProcessor is not running" do # The default TelemetryProcessor runs under the application supervisor, so it has diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex index b9494f06..8ed6ca2f 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/controllers/page_controller.ex @@ -29,6 +29,18 @@ defmodule PhoenixAppWeb.PageController do raise "Test exception" end + def traced_exception(conn, _params) do + Tracer.with_span "process_order" do + try do + raise "Traced exception" + rescue + exception -> Sentry.capture_exception(exception, stacktrace: __STACKTRACE__) + end + end + + render(conn, :home, layout: false) + end + def function_clause_error(_conn, %{"required" => _value}) do :ok end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex index 8887473b..19905cac 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/router.ex @@ -31,6 +31,7 @@ defmodule PhoenixAppWeb.Router do get "/", PageController, :home get "/exception", PageController, :exception + get "/traced-exception", PageController, :traced_exception get "/transaction", PageController, :transaction get "/nested-spans", PageController, :nested_spans get "/metrics", PageController, :metrics diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs index b4187c2f..58ab5244 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs @@ -4,8 +4,7 @@ defmodule Sentry.Integrations.Phoenix.ExceptionTest do import Sentry.Test.Assertions setup do - Sentry.Test.setup_sentry(traces_sample_rate: 1.0) - :ok + Sentry.Test.setup_sentry(collect_envelopes: true, traces_sample_rate: 1.0) end test "GET /exception sends exception to Sentry", %{conn: conn} do @@ -21,4 +20,21 @@ defmodule Sentry.Integrations.Phoenix.ExceptionTest do assert is_binary(event.event_id) end + + test "GET /traced-exception links the exception to the trace it was reported in", %{ + conn: conn, + ref: ref + } do + get(conn, ~p"/traced-exception") + + event = + assert_sentry_report(:event, + original_exception: %RuntimeError{message: "Traced exception"} + ) + + transaction = find_sentry_transaction!(ref, count: 2, transaction: "process_order") + + assert event.contexts.trace.trace_id == transaction["contexts"]["trace"]["trace_id"] + assert event.contexts.trace.span_id == transaction["contexts"]["trace"]["span_id"] + end end