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");
+ });
+ });
+ });
});