Skip to content

Commit 1a1bdd2

Browse files
authored
feat: distributed tracing (#957)
* feat(tracing): support for distributed tracing * Update CHANGELOG.md * Treat any span with remote parent as a transaction * feat: distributed tracing - e2e tests (#964) * chore(tests): add svelte app for e2e testing * chore(tests): add playwright for e2e testing * chore(tests): add `TestClient` for e2e tests * chore(tests): add e2e tests for tracing * chore(tests): add e2e github workflow
1 parent cf5ad4f commit 1a1bdd2

File tree

27 files changed

+3338
-32
lines changed

27 files changed

+3338
-32
lines changed

.github/workflows/e2e.yml

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
name: E2E Tracing Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
pull_request:
9+
10+
jobs:
11+
tracing-e2e:
12+
name: Tracing E2E Tests
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 15
15+
16+
steps:
17+
- name: Check out repository
18+
uses: actions/checkout@v4
19+
20+
- name: Setup Elixir and Erlang
21+
uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9
22+
with:
23+
elixir-version: "1.18"
24+
otp-version: "27.2"
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v4
28+
with:
29+
node-version: "20"
30+
31+
- name: Cache Elixir dependencies
32+
uses: actions/cache@v4
33+
with:
34+
path: |
35+
deps
36+
_build
37+
test_integrations/phoenix_app/deps
38+
test_integrations/phoenix_app/_build
39+
key: ${{ runner.os }}-elixir-1.18-otp-27.2-mix-${{ hashFiles('**/mix.lock') }}
40+
restore-keys: |
41+
${{ runner.os }}-elixir-1.18-otp-27.2-mix-
42+
43+
- name: Cache Node.js dependencies
44+
uses: actions/cache@v4
45+
with:
46+
path: |
47+
test_integrations/tracing/node_modules
48+
test_integrations/tracing/svelte_mini/node_modules
49+
key: ${{ runner.os }}-node-20-${{ hashFiles('test_integrations/tracing/package-lock.json', 'test_integrations/tracing/svelte_mini/package-lock.json') }}
50+
restore-keys: |
51+
${{ runner.os }}-node-20-
52+
53+
- name: Install main project dependencies
54+
run: mix deps.get
55+
56+
- name: Install Phoenix app dependencies
57+
working-directory: test_integrations/phoenix_app
58+
run: mix deps.get
59+
60+
- name: Compile Phoenix app
61+
working-directory: test_integrations/phoenix_app
62+
run: mix compile
63+
64+
- name: Install tracing test npm dependencies
65+
working-directory: test_integrations/tracing
66+
run: npm install
67+
68+
- name: Install Svelte app dependencies
69+
working-directory: test_integrations/tracing/svelte_mini
70+
run: npm install
71+
72+
- name: Install Playwright browsers
73+
working-directory: test_integrations/tracing
74+
run: npx playwright install --with-deps --only-shell chromium
75+
76+
- name: Start Phoenix server
77+
working-directory: test_integrations/phoenix_app
78+
run: |
79+
rm -f tmp/sentry_debug_events.log
80+
SENTRY_E2E_TEST_MODE=true mix phx.server &
81+
echo $! > /tmp/phoenix.pid
82+
echo "Phoenix server started with PID $(cat /tmp/phoenix.pid)"
83+
84+
- name: Start Svelte server
85+
working-directory: test_integrations/tracing/svelte_mini
86+
run: |
87+
SENTRY_E2E_SVELTE_APP_PORT=4001 npm run dev &
88+
echo $! > /tmp/svelte.pid
89+
echo "Svelte server started with PID $(cat /tmp/svelte.pid)"
90+
91+
- name: Wait for Phoenix server
92+
run: |
93+
echo "Waiting for Phoenix server at http://localhost:4000/health..."
94+
timeout 60 bash -c 'until curl -s http://localhost:4000/health > /dev/null 2>&1; do echo "Waiting..."; sleep 2; done'
95+
echo "Phoenix server is ready!"
96+
curl -s http://localhost:4000/health
97+
98+
- name: Wait for Svelte server
99+
run: |
100+
echo "Waiting for Svelte server at http://localhost:4001/health..."
101+
timeout 60 bash -c 'until curl -s http://localhost:4001/health > /dev/null 2>&1; do echo "Waiting..."; sleep 2; done'
102+
echo "Svelte server is ready!"
103+
curl -s http://localhost:4001/health
104+
105+
- name: Run Playwright tests
106+
working-directory: test_integrations/tracing
107+
env:
108+
SENTRY_E2E_PHOENIX_APP_URL: http://localhost:4000
109+
SENTRY_E2E_SVELTE_APP_URL: http://localhost:4001
110+
SENTRY_E2E_SERVERS_RUNNING: "true"
111+
run: npx playwright test --reporter=list
112+
113+
- name: Upload Playwright report
114+
uses: actions/upload-artifact@v4
115+
if: failure()
116+
with:
117+
name: playwright-report
118+
path: test_integrations/tracing/playwright-report/
119+
retention-days: 7
120+
121+
- name: Upload test screenshots
122+
uses: actions/upload-artifact@v4
123+
if: failure()
124+
with:
125+
name: test-screenshots
126+
path: test_integrations/tracing/test-results/
127+
retention-days: 7
128+
129+
- name: Show Phoenix server logs
130+
if: failure()
131+
run: |
132+
echo "=== Sentry debug events log ==="
133+
cat test_integrations/phoenix_app/tmp/sentry_debug_events.log 2>/dev/null || echo "No events logged"
134+
135+
- name: Cleanup servers
136+
if: always()
137+
run: |
138+
[ -f /tmp/phoenix.pid ] && kill $(cat /tmp/phoenix.pid) 2>/dev/null || true
139+
[ -f /tmp/svelte.pid ] && kill $(cat /tmp/svelte.pid) 2>/dev/null || true

.github/workflows/main.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ jobs:
5454
uses: actions/checkout@v4
5555

5656
- name: Setup Elixir and Erlang
57-
uses: erlef/setup-beam@v1
57+
uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9
5858
with:
5959
elixir-version: ${{ matrix.elixir }}
6060
otp-version: ${{ matrix.otp }}
@@ -117,6 +117,8 @@ jobs:
117117
run: mix test
118118

119119
- name: Run integration tests
120+
env:
121+
SKIP_TRACING_E2E: "true"
120122
run: mix test.integrations
121123

122124
- name: Cache Dialyzer PLT

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ test_integrations/phoenix_app/db
1717

1818
test_integrations/*/_build
1919
test_integrations/*/deps
20+
test_integrations/*/test-results/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## Unreleased
2+
3+
#### Features
4+
5+
- Support for Distributed Tracing ([957](https://github.com/getsentry/sentry-elixir/pull/957))
6+
17
## 11.0.4
28

39
- Fix safe JSON encoding of improper lists ([#938](https://github.com/getsentry/sentry-elixir/pull/938))
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
if Sentry.OpenTelemetry.VersionChecker.tracing_compatible?() do
2+
defmodule Sentry.OpenTelemetry.Propagator do
3+
@moduledoc """
4+
OpenTelemetry propagator for Sentry distributed tracing.
5+
6+
This propagator implements the `sentry-trace` and `sentry-baggage` header propagation
7+
to enable distributed tracing across service boundaries. It follows the W3C Trace Context.
8+
"""
9+
10+
import Bitwise
11+
12+
require Record
13+
require OpenTelemetry.Tracer, as: Tracer
14+
15+
@behaviour :otel_propagator_text_map
16+
17+
@fields Record.extract(:span_ctx, from_lib: "opentelemetry_api/include/opentelemetry.hrl")
18+
Record.defrecordp(:span_ctx, @fields)
19+
20+
@sentry_trace_key "sentry-trace"
21+
@sentry_baggage_key "baggage"
22+
@sentry_trace_ctx_key :"sentry-trace"
23+
@sentry_baggage_ctx_key :"sentry-baggage"
24+
25+
@impl true
26+
def fields(_opts) do
27+
[@sentry_trace_key, @sentry_baggage_key]
28+
end
29+
30+
@impl true
31+
def inject(ctx, carrier, setter, _opts) do
32+
case Tracer.current_span_ctx(ctx) do
33+
span_ctx(trace_id: tid, span_id: sid, trace_flags: flags) when tid != 0 and sid != 0 ->
34+
sentry_trace_header = encode_sentry_trace({tid, sid, flags})
35+
carrier = setter.(@sentry_trace_key, sentry_trace_header, carrier)
36+
37+
baggage_value = :otel_ctx.get_value(ctx, @sentry_baggage_ctx_key, :not_found)
38+
39+
if is_binary(baggage_value) and baggage_value != :not_found do
40+
setter.(@sentry_baggage_key, baggage_value, carrier)
41+
else
42+
carrier
43+
end
44+
45+
_ ->
46+
carrier
47+
end
48+
end
49+
50+
@impl true
51+
def extract(ctx, carrier, _keys_fun, getter, _opts) do
52+
case getter.(@sentry_trace_key, carrier) do
53+
:undefined ->
54+
ctx
55+
56+
header when is_binary(header) ->
57+
case decode_sentry_trace(header) do
58+
{: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))
63+
64+
trace_id = hex_to_int(trace_hex)
65+
span_id = hex_to_int(span_hex)
66+
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)
70+
71+
Tracer.set_current_span(ctx, remote_span_ctx)
72+
73+
{:error, _reason} ->
74+
ctx
75+
end
76+
77+
_ ->
78+
ctx
79+
end
80+
end
81+
82+
# Encode trace ID, span ID, and sampled flag to sentry-trace header format
83+
# Format: {trace_id}-{span_id}-{sampled}
84+
defp encode_sentry_trace({trace_id_int, span_id_int, trace_flags}) do
85+
sampled = if (trace_flags &&& 1) == 1, do: "1", else: "0"
86+
int_to_hex(trace_id_int, 16) <> "-" <> int_to_hex(span_id_int, 8) <> "-" <> sampled
87+
end
88+
89+
# Decode sentry-trace header
90+
# Format: {trace_id}-{span_id}-{sampled} or {trace_id}-{span_id}
91+
defp decode_sentry_trace(
92+
<<trace_hex::binary-size(32), "-", span_hex::binary-size(16), "-",
93+
sampled::binary-size(1)>>
94+
) do
95+
{:ok, {trace_hex, span_hex, sampled == "1"}}
96+
end
97+
98+
defp decode_sentry_trace(<<trace_hex::binary-size(32), "-", span_hex::binary-size(16)>>) do
99+
{:ok, {trace_hex, span_hex, false}}
100+
end
101+
102+
defp decode_sentry_trace(_invalid) do
103+
{:error, :invalid_format}
104+
end
105+
106+
defp maybe_set_baggage(ctx, :undefined), do: ctx
107+
defp maybe_set_baggage(ctx, ""), do: ctx
108+
defp maybe_set_baggage(ctx, nil), do: ctx
109+
110+
defp maybe_set_baggage(ctx, baggage) when is_binary(baggage) do
111+
:otel_ctx.set_value(ctx, @sentry_baggage_ctx_key, baggage)
112+
end
113+
114+
# Convert hex string to integer
115+
defp hex_to_int(hex) do
116+
hex
117+
|> Base.decode16!(case: :mixed)
118+
|> :binary.decode_unsigned()
119+
end
120+
121+
# Convert integer to hex string with padding
122+
defp int_to_hex(value, num_bytes) do
123+
value
124+
|> :binary.encode_unsigned()
125+
|> bin_pad_left(num_bytes)
126+
|> Base.encode16(case: :lower)
127+
end
128+
129+
# Pad binary to specified number of bytes
130+
defp bin_pad_left(bin, total_bytes) do
131+
missing = total_bytes - byte_size(bin)
132+
if missing > 0, do: :binary.copy(<<0>>, missing) <> bin, else: bin
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)