Skip to content

Conversation

@solnic
Copy link
Collaborator

@solnic solnic commented Jan 20, 2026

This adds support for connecting LiveView spans with their HTTP-based mount span so that transactions that took place on a given LiveView page are nested under the same trace.

Example in UI

Screenshot 2026-01-21 at 09 21 02

LiveView and Phoenix HTTP Span Processing Overview

TL;DR: it works


This document describes how OpenTelemetry spans from Phoenix LiveView and HTTP requests are processed and converted into Sentry transactions by the Sentry.OpenTelemetry.SpanProcessor.

Overview

The span processor receives OTel spans from various instrumentation libraries:

  • opentelemetry_bandit: HTTP server spans
  • opentelemetry_phoenix: LiveView lifecycle spans (mount, handle_params, handle_event)
  • opentelemetry_ecto: Database query spans

Span Flow Diagram

sequenceDiagram
    participant Browser
    participant Bandit as Bandit (HTTP)
    participant Phoenix as Phoenix LiveView
    participant Processor as SpanProcessor
    participant Storage as SpanStorage
    participant Sentry

    Note over Browser,Sentry: Phase 1: Static Render (HTTP Request)

    Browser->>Bandit: GET /tracing-test
    Bandit->>Processor: on_start(HTTP span)
    Processor->>Storage: store_span(HTTP)

    Phoenix->>Processor: on_start(mount span, parent=HTTP)
    Processor->>Storage: store_span(mount)
    Phoenix->>Processor: on_end(mount span)
    Processor->>Storage: update_span(mount)
    Note over Processor: mount has local parent, not a transaction root

    Phoenix->>Processor: on_start(handle_params span, parent=HTTP)
    Processor->>Storage: store_span(handle_params)
    Phoenix->>Processor: on_end(handle_params span)
    Processor->>Storage: update_span(handle_params)
    Note over Processor: handle_params has local parent, not a transaction root

    Bandit->>Processor: on_end(HTTP span)
    Processor->>Storage: get_child_spans(HTTP)
    Storage-->>Processor: [mount, handle_params]
    Processor->>Sentry: send_transaction(HTTP + children)
    Processor->>Storage: remove_root_span(HTTP)

    Bandit-->>Browser: HTML Response

    Note over Browser,Sentry: Phase 2: WebSocket Upgrade

    Browser->>Bandit: GET /live/websocket (upgrade)
    Bandit->>Processor: on_start(WS upgrade span)
    Processor->>Storage: store_span(WS upgrade)
    Bandit->>Processor: on_end(WS upgrade span)
    Processor->>Sentry: send_transaction(WS upgrade, no children)
    Note over Processor: WebSocket upgrade has no parent, becomes transaction

    Bandit-->>Browser: 101 Switching Protocols

    Note over Browser,Sentry: Phase 3: LiveView WebSocket Events

    Browser->>Phoenix: WebSocket: join
    Phoenix->>Processor: on_start(mount span, parent=old HTTP span_id)
    Processor->>Storage: store_span(mount)
    Phoenix->>Processor: on_end(mount span)
    Note over Processor: Parent not in storage (already cleaned up)
    Note over Processor: mount is server span, becomes transaction root
    Processor->>Sentry: send_transaction(mount)

    Phoenix->>Processor: on_start(handle_params span, parent=old HTTP span_id)
    Processor->>Storage: store_span(handle_params)
    Phoenix->>Processor: on_end(handle_params span)
    Note over Processor: Parent not in storage
    Processor->>Sentry: send_transaction(handle_params)

    Browser->>Phoenix: WebSocket: event (click)
    Phoenix->>Processor: on_start(handle_event span)
    Processor->>Storage: store_span(handle_event)
    Phoenix->>Processor: on_end(handle_event span)
    Processor->>Sentry: send_transaction(handle_event)
Loading

Transaction Root Detection Logic

The span processor uses the following logic to determine if a span should become a transaction root:

flowchart TD
    A[Span Ends] --> B{Has parent_span_id?}
    B -->|No| C[Transaction Root]
    B -->|Yes| D{Parent in SpanStorage?}
    D -->|Yes| E[Child Span - attach to parent]
    D -->|No| F{Is server span?}
    F -->|No| G[Orphaned - becomes transaction]
    F -->|Yes| H{HTTP or LiveView span?}
    H -->|Yes| C
    H -->|No| G

    C --> I[Build transaction with children]
    I --> J[Send to Sentry]
    J --> K[Clean up from SpanStorage]
Loading

Span Types and Their Behavior

1. HTTP Server Spans (opentelemetry_bandit)

Characteristics:

  • kind: :server
  • Has http.request.method attribute
  • parent_span_id: nil for incoming requests
  • Origin: opentelemetry_bandit

Processing:

  • Always becomes a transaction root (no parent)
  • Collects child spans (mount, handle_params, DB queries during static render)

Example:

{
  "name": "GET /tracing-test",
  "kind": "server",
  "span_id": "e861c0f9bda78951",
  "parent_span_id": null,
  "attributes": {
    "http.request.method": "GET",
    "http.route": "/tracing-test",
    "http.response.status_code": 200
  }
}

2. LiveView Mount Spans (opentelemetry_phoenix)

Characteristics:

  • kind: :server
  • Name ends with .mount
  • Origin: opentelemetry_phoenix
  • parent_span_id points to HTTP span

Two Contexts:

  1. Static Render Mount (during HTTP request):

    • Parent span exists in SpanStorage
    • Becomes a child span of the HTTP transaction
  2. Connected Mount (after WebSocket connects):

    • Parent span no longer in SpanStorage (HTTP request completed)
    • Becomes its own transaction root

Example:

{
  "name": "PhoenixAppWeb.TracingTestLive.mount",
  "kind": "server",
  "span_id": "2fb4aee27d9a781a",
  "parent_span_id": "e861c0f9bda78951",
  "origin": "opentelemetry_phoenix"
}

3. LiveView handle_params Spans

Characteristics:

  • kind: :server
  • Name ends with .handle_params
  • Same dual behavior as mount spans

4. LiveView handle_event Spans

Characteristics:

  • kind: :server
  • Name contains .handle_event# or ends with .handle_event
  • Always occurs over WebSocket (parent not in storage)
  • Always becomes its own transaction

Example:

{
  "name": "PhoenixAppWeb.TracingTestLive.handle_event#increment",
  "kind": "server",
  "span_id": "a31eb359281216c7",
  "parent_span_id": "8a32e222e14a7e06"
}

5. Database Spans (opentelemetry_ecto)

Characteristics:

  • kind: :client
  • Has db.system and db.statement attributes
  • Origin: opentelemetry_ecto

Processing:

  • Always becomes a child span (never a transaction root)
  • Properly nested under parent spans (HTTP, handle_event, etc.)

Example:

{
  "name": "phoenix_app.repo.query:users",
  "kind": "client",
  "span_id": "872aa68385ff20bc",
  "parent_span_id": "803dea46f1866d76",
  "attributes": {
    "db.system": "sqlite",
    "db.statement": "SELECT ... FROM users"
  }
}

Resulting Transaction Structure

Static Render Transaction

graph TD
    A["GET /tracing-test<br/>(http.server)"] --> B["LiveView.mount<br/>(server)"]
    A --> C["LiveView.handle_params<br/>(server)"]
Loading

WebSocket Event Transaction

graph TD
    A["handle_event#fetch_data<br/>(server)"] --> B["fetch_data<br/>(internal)"]
    B --> C["repo.query:users<br/>(db)"]
    B --> D["process_data<br/>(internal)"]
    D --> E["repo.query:users<br/>(db)"]
Loading

Key Implementation Details

SpanStorage

The SpanStorage module maintains spans in ETS tables:

  • Stores spans on on_start
  • Updates spans on on_end
  • Provides get_child_spans/1 to collect children by parent_span_id
  • Cleans up with remove_root_span/1 after transaction is sent

Transaction Detection

is_transaction_root =
  cond do
    # No parent = definitely a root
    span_record.parent_span_id == nil ->
      true

    # Has a parent - check if it's local or remote
    true ->
      has_local_parent = SpanStorage.span_exists?(span_record.parent_span_id)

      if has_local_parent do
        # Parent exists locally - this is a child span
        false
      else
        # Parent is remote/gone - treat server spans as transaction roots
        is_server_span?(span_record)
      end
  end

LiveView Span Detection

LiveView spans are detected by checking the origin field (derived from instrumentation_scope.name):

defp is_liveview_span?(%{origin: "opentelemetry_phoenix"}), do: true
defp is_liveview_span?(_), do: false

The opentelemetry_phoenix library only creates spans for:

  • [:phoenix, :live_view, :mount, ...]
  • [:phoenix, :live_view, :handle_params, ...]
  • [:phoenix, :live_view, :handle_event, ...]
  • [:phoenix, :live_component, :handle_event, ...]

Summary

Span Type During Static Render During WebSocket
HTTP GET Transaction root Transaction root (upgrade)
LiveView.mount Child of HTTP Transaction root (same trace_id)
LiveView.handle_params Child of HTTP Transaction root (same trace_id)
LiveView.handle_event N/A Transaction root (same trace_id)
DB Query Child of parent Child of parent

Trace Continuity via LiveView Propagator

While LiveView WebSocket spans become separate transactions, they share the same trace_id as the original HTTP request. This is enabled by:

  1. Sentry.Plug.LiveViewContext - stores trace context in the session during HTTP request
  2. Sentry.OpenTelemetry.LiveViewPropagator - restores context in LiveView processes before opentelemetry_phoenix creates spans

Example trace correlation:

trace_id: 63e3144898692f9c51aa4031e87611ac
├── Transaction: GET /tracing-test (HTTP)
│   └── Span: TracingTestLive.mount (static render)
│   └── Span: TracingTestLive.handle_params (static render)
├── Transaction: TracingTestLive.mount (WebSocket - same trace_id!)
├── Transaction: TracingTestLive.handle_params (WebSocket - same trace_id!)
└── Transaction: TracingTestLive.handle_event#click (WebSocket - same trace_id!)

Why separate transactions but same trace?

The spans become separate transactions because the parent HTTP span has already completed and been cleaned up from SpanStorage. However, the propagator ensures they share the same trace_id, so Sentry can correlate them in the trace view.

This is the correct behavior because:

  • HTTP requests can't wait indefinitely for WebSocket events
  • WebSocket events may occur seconds/minutes after the initial page load
  • Sentry's trace view still shows all events connected via the shared trace_id

Refs #857

@solnic solnic linked an issue Jan 20, 2026 that may be closed by this pull request
@github-actions
Copy link

github-actions bot commented Jan 20, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 20b2ab4

@solnic solnic force-pushed the 857-phoenix-live-view-support branch 9 times, most recently from b23d545 to 8f3ea9f Compare January 21, 2026 11:40
@solnic solnic marked this pull request as ready for review January 21, 2026 11:50
@solnic solnic force-pushed the 857-phoenix-live-view-support branch 2 times, most recently from 26ee0fc to 4e68b1e Compare January 21, 2026 12:00
@solnic solnic force-pushed the 857-phoenix-live-view-support branch from 4e68b1e to 8e02c59 Compare January 21, 2026 12:04
@solnic solnic force-pushed the 857-phoenix-live-view-support branch from 27aa889 to bcc324c Compare January 23, 2026 09:34
@solnic solnic force-pushed the 857-phoenix-live-view-support branch from bcc324c to 20b2ab4 Compare January 23, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phoenix Live View Support

2 participants