diff --git a/lib/sentry/opentelemetry/span_processor.ex b/lib/sentry/opentelemetry/span_processor.ex index b51c036f..b7fac400 100644 --- a/lib/sentry/opentelemetry/span_processor.ex +++ b/lib/sentry/opentelemetry/span_processor.ex @@ -64,11 +64,15 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do SpanStorage.span_exists?(parent_span_id) end - # Check if it's an HTTP server request span or a LiveView span + # Check if it's an HTTP server request span, a LiveView span, or an Oban consumer span defp server_span?(%{kind: :server} = span_record) do http_server_span?(span_record) or liveview_span?(span_record) end + defp server_span?(%{kind: :consumer} = span_record) do + oban_consumer_span?(span_record) + end + defp server_span?(_), do: false defp http_server_span?(%{kind: :server, attributes: attributes}) do @@ -79,6 +83,10 @@ if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do defp liveview_span?(%{origin: "opentelemetry_phoenix"}), do: true defp liveview_span?(_), do: false + defp oban_consumer_span?(%{kind: :consumer, attributes: attributes}) do + Map.get(attributes, to_string(MessagingAttributes.messaging_system())) == :oban + end + defp build_and_send_transaction(span_record) do child_span_records = SpanStorage.get_child_spans(span_record.span_id) transaction = build_transaction(span_record, child_span_records) diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index 9c7997b5..9a43734f 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -32,12 +32,12 @@ config :phoenix_live_view, enable_expensive_runtime_checks: true config :sentry, - dsn: "https://public@sentry.example.com/1", + dsn: nil, environment_name: :dev, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], test_mode: true, - send_result: :sync, + send_result: :none, traces_sample_rate: 1.0, enable_logs: true, logs: [ 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 b5bee802..252ddb45 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 @@ -172,4 +172,24 @@ defmodule PhoenixAppWeb.PageController do } }) end + + def api_oban_job(conn, params) do + alias PhoenixApp.Workers.TestWorker + + sleep_time = Map.get(params, "sleep_time", "100") |> String.to_integer() + should_fail = Map.get(params, "should_fail", "false") == "true" + + {:ok, job} = + %{"sleep_time" => sleep_time, "should_fail" => should_fail} + |> TestWorker.new() + |> OpentelemetryOban.insert() + + json(conn, %{ + job_id: job.id, + worker: job.worker, + queue: job.queue, + args: job.args, + enqueued: true + }) + end end diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex index 0ba8562a..6b750a6a 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.ex @@ -79,7 +79,7 @@ defmodule PhoenixAppWeb.TestWorkerLive do %{"sleep_time" => sleep_time, "should_fail" => should_fail}, queue: queue ) - |> Oban.insert() + |> OpentelemetryOban.insert() end defp list_jobs do diff --git a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex index 432f7478..e1e1576c 100644 --- a/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex +++ b/test_integrations/phoenix_app/lib/phoenix_app_web/live/test_worker_live.html.heex @@ -9,6 +9,7 @@
+ +
{#if result} diff --git a/test_integrations/tracing/tests/helpers.ts b/test_integrations/tracing/tests/helpers.ts index 02f092b9..c1c264fc 100644 --- a/test_integrations/tracing/tests/helpers.ts +++ b/test_integrations/tracing/tests/helpers.ts @@ -23,6 +23,11 @@ export interface SentryEvent { parent_span_id?: string; op?: string; data?: Record; + links?: Array<{ + trace_id: string; + span_id: string; + attributes?: Record; + }>; }; }; _meta?: { diff --git a/test_integrations/tracing/tests/tracing.spec.ts b/test_integrations/tracing/tests/tracing.spec.ts index 37f1d758..605e3448 100644 --- a/test_integrations/tracing/tests/tracing.spec.ts +++ b/test_integrations/tracing/tests/tracing.spec.ts @@ -422,6 +422,7 @@ test.describe("Tracing", () => { await page.goto(`${PHOENIX_URL}/tracing-test`); await expect(page.locator("#tracing-test-live h1")).toContainText("LiveView Tracing Test"); + await expect(page.locator("#counter-value")).toHaveText("0"); await page.click("#increment-btn"); await expect(page.locator("#counter-value")).toHaveText("1"); @@ -547,4 +548,179 @@ test.describe("Tracing", () => { expect(allSpanIds.has(dbSpan.parent_span_id!)).toBe(true); }); }); + + test.describe("Oban job tracing", () => { + const PHOENIX_URL = process.env.SENTRY_E2E_PHOENIX_APP_URL || "http://localhost:4000"; + + test("LiveView-scheduled Oban job generates transaction with valid trace context", async ({ page }) => { + await page.goto(`${PHOENIX_URL}/test-worker`); + + await expect(page.locator("h3").first()).toContainText("Schedule Test Worker"); + await expect(page.locator("#schedule-job-btn")).toBeVisible(); + + // Set a short sleep time so the job completes quickly + await page.fill("#sleep-time-input", "10"); + + clearLoggedEvents(); + + await page.click("#schedule-job-btn"); + await expect(page.locator("#flash-info")).toContainText("Job scheduled successfully!"); + + // Wait for the Oban job transaction + const logged = await waitForEvents( + (events) => + events.events.some( + (e) => + e.type === "transaction" && + e.contexts?.trace?.op === "queue.process" + ), + { timeout: 10000 } + ); + + const obanTransactions = logged.events.filter( + (event) => + event.type === "transaction" && + event.contexts?.trace?.op === "queue.process" + ) as TransactionWithSpans[]; + + expect(obanTransactions.length).toBeGreaterThan(0); + + const obanTransaction = obanTransactions[0]; + const traceContext = obanTransaction.contexts?.trace; + + expect(obanTransaction.transaction).toBe("PhoenixApp.Workers.TestWorker"); + + expect(traceContext).toBeDefined(); + expect(traceContext?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(traceContext?.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(traceContext?.op).toBe("queue.process"); + + const traceData = traceContext?.data as Record | undefined; + expect(traceData).toBeDefined(); + expect(traceData?.["messaging.destination.name"]).toBe("default"); + expect(traceData?.["oban.job.attempt"]).toBe(1); + }); + + test("LiveView-scheduled Oban job has span link back to the LiveView trace", async ({ page }) => { + // Full distributed tracing path: + // LiveView WebSocket event → handle_event → OpentelemetryOban.insert() → Oban job + // The Oban job transaction should have a span link whose trace_id matches + // the LiveView transaction's trace_id, proving the causal relationship. + await page.goto(`${PHOENIX_URL}/test-worker`); + + await expect(page.locator("#schedule-job-btn")).toBeVisible(); + await page.fill("#sleep-time-input", "10"); + + clearLoggedEvents(); + + await page.click("#schedule-job-btn"); + await expect(page.locator("#flash-info")).toContainText("Job scheduled successfully!"); + + // Wait for both the LiveView transaction and the Oban job transaction + const logged = await waitForEvents( + (events) => { + const transactions = events.events.filter((e) => e.type === "transaction"); + const hasLiveView = transactions.some( + (e) => e.transaction?.includes("handle_event") + ); + const hasOban = transactions.some( + (e) => + e.type === "transaction" && + e.contexts?.trace?.op === "queue.process" + ); + return hasLiveView && hasOban; + }, + { timeout: 10000 } + ); + + const transactions = logged.events.filter( + (event) => event.type === "transaction" + ); + + // Find the LiveView transaction that handled the form submission + const liveViewTransactions = transactions.filter( + (t) => + t.transaction?.includes("handle_event") || + (t.contexts?.trace?.data as any)?.["url.path"]?.includes("/live/websocket") + ); + expect(liveViewTransactions.length).toBeGreaterThan(0); + + // Collect all LiveView trace IDs (the job could be linked to any of them) + const liveViewTraceIds = new Set( + liveViewTransactions + .map((t) => t.contexts?.trace?.trace_id) + .filter(Boolean) as string[] + ); + + // Find the Oban job transaction + const obanTransaction = transactions.find( + (t) => t.contexts?.trace?.op === "queue.process" + ); + expect(obanTransaction).toBeDefined(); + + const obanTrace = obanTransaction!.contexts?.trace; + expect(obanTrace?.op).toBe("queue.process"); + expect(obanTrace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + + // The span link should connect the Oban transaction back to one of the LiveView traces + const links = obanTrace?.links; + expect(links).toBeDefined(); + expect(links!.length).toBeGreaterThan(0); + + for (const link of links!) { + expect(link.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(link.trace_id).toMatch(/^[a-f0-9]{32}$/); + } + expect(links!.some((link) => liveViewTraceIds.has(link.trace_id))).toBe(true); + }); + + test("Multiple LiveView-scheduled Oban jobs create independent transactions", async ({ page }) => { + await page.goto(`${PHOENIX_URL}/test-worker`); + + await expect(page.locator("#schedule-job-btn")).toBeVisible(); + await page.fill("#sleep-time-input", "10"); + + clearLoggedEvents(); + + // Schedule multiple jobs via the LiveView form + for (let i = 0; i < 3; i++) { + await page.click("#schedule-job-btn"); + await expect(page.locator("#flash-info")).toContainText("Job scheduled successfully!"); + } + + // Wait for all Oban job transactions + const logged = await waitForEvents( + (events) => + events.events.filter( + (e) => + e.type === "transaction" && + e.contexts?.trace?.op === "queue.process" + ).length >= 3, + { timeout: 10000 } + ); + + const obanTransactions = logged.events.filter( + (event) => + event.type === "transaction" && + event.contexts?.trace?.op === "queue.process" + ); + + expect(obanTransactions.length).toBeGreaterThanOrEqual(3); + + // Each job should have its own trace_id (independent traces) + const traceIds = obanTransactions + .map((t) => t.contexts?.trace?.trace_id) + .filter(Boolean); + + const uniqueTraceIds = [...new Set(traceIds)]; + expect(uniqueTraceIds.length).toBeGreaterThanOrEqual(3); + + obanTransactions.forEach((transaction) => { + const traceContext = transaction.contexts?.trace; + expect(traceContext?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(traceContext?.span_id).toMatch(/^[a-f0-9]{16}$/); + expect(traceContext?.op).toBe("queue.process"); + }); + }); + }); });