tracing: hand spans to Sentry and VictoriaTraces via OTel
OpenTelemetry makes the spans, and Sentry builds its transactions from that feed;
the status lands under a key its filter never reads, so Tracing.scrub drops the statuses it should not keep.
The bridge copies every attribute, query string too, where send_default_pii has no say,
so scrub cuts url.query; set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and VictoriaTraces gets the spans unscrubbed anyway.
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/Gemfile b/Gemfile
index b5a4010..1d5d8fb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,11 +4,15 @@ gem "base64"
gem "irb"
gem "logger"
gem "nokogiri"
+gem "opentelemetry-exporter-otlp"
+gem "opentelemetry-instrumentation-rack"
+gem "opentelemetry-sdk"
gem "phlex"
gem "puma"
gem "rackup"
gem "rake"
gem "roda"
+gem "sentry-opentelemetry"
gem "sentry-ruby"
gem "sequel"
gem "sqlite3"
diff --git a/Gemfile.lock b/Gemfile.lock
index aec3c3e..02ff1e4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -12,6 +12,17 @@ GEM
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
fileutils (1.8.0)
+ google-protobuf (4.36.2-aarch64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.2-arm64-darwin)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.2-x86_64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ googleapis-common-protos-types (1.23.0)
+ google-protobuf (~> 4.26)
hegeltest (0.1.1-aarch64-linux)
ffi (~> 1.17, >= 1.17.4)
hegeltest (0.1.1-arm64-darwin)
@@ -41,6 +52,45 @@ GEM
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
+ opentelemetry-api (1.11.1)
+ logger
+ opentelemetry-common (0.25.2)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-exporter-otlp (0.37.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-exporter-otlp-common (~> 0.2)
+ opentelemetry-sdk (~> 1.10)
+ opentelemetry-semantic_conventions
+ opentelemetry-exporter-otlp-common (0.3.0)
+ google-protobuf (>= 3.18)
+ googleapis-common-protos-types (~> 1.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-instrumentation-base (0.27.0)
+ opentelemetry-api (~> 1.7)
+ opentelemetry-common (~> 0.21)
+ opentelemetry-logs-api (~> 0.6)
+ opentelemetry-metrics-api (~> 0.9)
+ opentelemetry-registry (~> 0.1)
+ opentelemetry-instrumentation-rack (0.31.2)
+ opentelemetry-instrumentation-base (~> 0.25)
+ opentelemetry-logs-api (0.6.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-metrics-api (0.9.0)
+ opentelemetry-api (~> 1.0)
+ opentelemetry-registry (0.6.3)
+ opentelemetry-api (~> 1.1)
+ opentelemetry-sdk (1.13.1)
+ logger
+ opentelemetry-api (~> 1.1)
+ opentelemetry-common (~> 0.20)
+ opentelemetry-registry (~> 0.2)
+ opentelemetry-semantic_conventions
+ opentelemetry-semantic_conventions (1.44.0)
+ opentelemetry-api (~> 1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
@@ -85,6 +135,9 @@ GEM
prism (>= 1.2, < 2.0)
rbs (>= 3, < 5)
securerandom (0.4.1)
+ sentry-opentelemetry (6.7.0)
+ opentelemetry-sdk (~> 1.0)
+ sentry-ruby (~> 6.7.0)
sentry-ruby (6.7.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -132,6 +185,9 @@ DEPENDENCIES
logger
minitest
nokogiri
+ opentelemetry-exporter-otlp
+ opentelemetry-instrumentation-rack
+ opentelemetry-sdk
phlex
puma
rack-test
@@ -139,6 +195,7 @@ DEPENDENCIES
rake
roda
ruby-lsp
+ sentry-opentelemetry
sentry-ruby
sequel
sqlite3
@@ -156,6 +213,10 @@ CHECKSUMS
ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+ google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169
+ google-protobuf (4.36.2-arm64-darwin) sha256=f70d810e6bbab9d8ad9c1d5fd685a21de4c10ad3ad32efb9455e17e5b1286917
+ google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db
+ googleapis-common-protos-types (1.23.0) sha256=992e740a523794d9fc5f29a504465d8fc737aaa16c930fe7228e3346860faf0a
hegeltest (0.1.1-aarch64-linux) sha256=9a52c25200b09930cac91a2056005cbdb778962aa07049f5e1e9dd7b74e88891
hegeltest (0.1.1-arm64-darwin) sha256=885c9b16fb3aaee5f2c5c6ab3d2b2d990c536334f78f3c21ebd456defa4115e0
hegeltest (0.1.1-x86_64-linux) sha256=1d16acb3841db1f1fc06ff00ce3af045c7861c92f7641f9f22c765e2c23d74ab
@@ -170,6 +231,17 @@ CHECKSUMS
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
+ opentelemetry-api (1.11.1) sha256=d07f9987ea6240b3fd5ed5dd7450f93c58e403e0553e804069c23ae18ba61ff7
+ opentelemetry-common (0.25.2) sha256=c035d44987789356fe8feeeb870708b44fe7564ffc032dec7200c39da92a911d
+ opentelemetry-exporter-otlp (0.37.0) sha256=edc8bda657dce5f4d527ac735f75d44043bb5924de6df0e3f01260108aa76136
+ opentelemetry-exporter-otlp-common (0.3.0) sha256=855d10d788a7272dfe5b14efa229507619abbef29085ef4a8acfd2c0d3af6171
+ opentelemetry-instrumentation-base (0.27.0) sha256=e55edb82cd0b018e82e746be61fa36cb05b7b68b2fa37f0e4a96b923ad3d7735
+ opentelemetry-instrumentation-rack (0.31.2) sha256=4a183066110455711a0c77754e3f46652f646221d7704d968a48d155b47f8309
+ opentelemetry-logs-api (0.6.0) sha256=b889ac61d90326bffadb83bf7278d246d1086e2349c9052aeb072ed375ce73d0
+ opentelemetry-metrics-api (0.9.0) sha256=e11a12055113e11421313f71c758e5e10e4662e9e5c3b91786218a692e81206d
+ opentelemetry-registry (0.6.3) sha256=568fa8eaea94394f29f4fce3f0339ab10c3bf6d6c8beed6b3cfee6fd37fcc608
+ opentelemetry-sdk (1.13.1) sha256=91c9cc315ac7ef30243549eeff8b27cd0d39bd02bdf9c9ae481ab96a1cec6754
+ opentelemetry-semantic_conventions (1.44.0) sha256=b09b8793422f13b01c08f7a6f29b5f511f47f37bcdb57c0513465a0464168513
parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
@@ -191,6 +263,7 @@ CHECKSUMS
roda (3.107.0) sha256=bf40c7a0f1fcd037ec10589a1dc98fc52ced1aece803670194ec7af0f0c77c06
ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ sentry-opentelemetry (6.7.0) sha256=dfe6543e6f3243cee35feb1e47e77a8b58d15e0edfbd422738d2e5ab0c0c29da
sentry-ruby (6.7.0) sha256=b4b915d79a0395ad02106d4012aa1914641456e042ce36393f15bd1630d8db42
sequel (5.107.0) sha256=019629313205295b007f6df2c5eab19ddc40710d549fd445ac300f2853a370db
sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c
diff --git a/config.ru b/config.ru
index 6a57ea8..275ade7 100644
--- a/config.ru
+++ b/config.ru
@@ -8,6 +8,7 @@ require "sentry-ruby"
$LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
require "pro_tacts/web"
require "pro_tacts/store"
+require "pro_tacts/tracing"
config = ProTacts.config
@@ -39,7 +40,9 @@ Sentry.init do |sentry|
event
end
sentry.before_send = drop_identity
- sentry.before_send_transaction = drop_identity
+ sentry.before_send_transaction = lambda do |event, hint|
+ ProTacts::Tracing.scrub(drop_identity.call(event, hint))
+ end
# The exchange log is a local-only record of whole exchanges, bodies
# and all. Sentry's sentry_logger hook would otherwise turn it into
@@ -48,9 +51,11 @@ Sentry.init do |sentry|
sentry.exclude_loggers = [ProTacts::ExchangeLog::PROGNAME]
sentry.traces_sample_rate = 1.0
- sentry.trace_ignore_status_codes = ProTacts::Web::UNTRACED_STATUSES
+ sentry.instrumenter = :otel
end
+ProTacts::Tracing.configure(config.traces_endpoint)
ProTacts::Web.plugin :common_logger, $stderr
+use(*ProTacts::Tracing.middleware)
run ProTacts::Web.freeze.app
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index e49f41b..6d42c13 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -23,6 +23,14 @@ module ProTacts
@env.fetch("SENTRY_DSN", nil)
end
+ # Where traces are sent over OTLP/HTTP, as the full URL — for
+ # VictoriaTraces, http://<host>:10428/insert/opentelemetry/v1/traces.
+ # Unset, traces reach Sentry alone. See ProTacts::Tracing.
+ #: () -> String?
+ def traces_endpoint
+ @env.fetch("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", nil)
+ end
+
# Whether the exchange log keeps every DAV exchange rather than the
# ones that went wrong, so a write that succeeded can be read back —
# which is how a client's handling of a property gets probed at all
diff --git a/lib/pro_tacts/tracing.rb b/lib/pro_tacts/tracing.rb
new file mode 100644
index 0000000..1174c18
--- /dev/null
+++ b/lib/pro_tacts/tracing.rb
@@ -0,0 +1,65 @@
+require "opentelemetry/sdk"
+require "opentelemetry/exporter/otlp"
+require "opentelemetry/instrumentation/rack"
+require "sentry-ruby"
+require "sentry-opentelemetry"
+
+module ProTacts
+ # Tracing runs on OpenTelemetry, which hands every span to Sentry and,
+ # when an endpoint is configured, to an OTLP collector such as
+ # VictoriaTraces. Sentry's own instrumentation is off (config.ru's
+ # instrumenter), so its transactions are built from these spans.
+ module Tracing
+ # A proxy until configure installs the SDK, which it then delegates to,
+ # so the app can hold it from require time.
+ TRACER = OpenTelemetry.tracer_provider.tracer("pro_tacts") #: OpenTelemetry::Trace::Tracer
+
+ # The statuses whose transactions Sentry drops: sentry-ruby's default
+ # (TRACE_IGNORE_STATUS_CODES_DEFAULT) less 303, which answers every
+ # admin write, so an upload or a Save is traced. Applied by scrub,
+ # because Sentry's trace_ignore_status_codes never sees the status of
+ # a span it did not start.
+ UNTRACED_STATUSES = [(301..302), (305..399), (401..404)].freeze #: Array[Range[Integer]]
+
+ #: (String? endpoint) -> void
+ def self.configure(endpoint)
+ OpenTelemetry::SDK.configure do |otel|
+ otel.service_name = "pro-tacts"
+ # Named by route rather than the method alone, which would make
+ # every request one Sentry transaction. PATH_INFO, not the request
+ # URI the instrumentation offers, so the name carries no query.
+ otel.use "OpenTelemetry::Instrumentation::Rack",
+ url_quantization: ->(_uri, env) { "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" }
+ otel.add_span_processor(Sentry::OpenTelemetry::SpanProcessor.instance)
+ if endpoint
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint:)
+ otel.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
+ end
+ end
+ OpenTelemetry.propagation = Sentry::OpenTelemetry::Propagator.new
+ end
+
+ # The request span's middleware, for the outside of the app's stack.
+ #: () -> Array[untyped]
+ def self.middleware
+ OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args
+ end
+
+ # For before_send_transaction. Sentry copies every attribute of the
+ # request span into the transaction's otel context, which
+ # send_default_pii does not reach, so the query string goes here.
+ #: (Sentry::TransactionEvent event) -> Sentry::TransactionEvent?
+ def self.scrub(event)
+ otel = event.contexts[:otel]
+ attributes = otel&.[](:attributes)
+ return event unless otel && attributes
+
+ status = attributes["http.response.status_code"]
+ return if status.is_a?(Integer) && UNTRACED_STATUSES.any? { it.cover?(status) }
+
+ # A finished span's attributes are frozen.
+ otel[:attributes] = attributes.except("url.query")
+ event
+ end
+ end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 81692e1..4f14c01 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -12,6 +12,7 @@ require "pro_tacts/exchange_log"
require "pro_tacts/refusal_alerts"
require "pro_tacts/store"
require "pro_tacts/proxy_auth"
+require "pro_tacts/tracing"
require "roda/plugins/dav_verbs"
module ProTacts
@@ -30,11 +31,6 @@ module ProTacts
__dir__ #: String
).parent.parent / "public" #: Pathname
- # The statuses whose transactions Sentry drops, for config.ru:
- # sentry-ruby's default (TRACE_IGNORE_STATUS_CODES_DEFAULT) less 303,
- # which answers every admin write, so an upload or a Save is traced.
- UNTRACED_STATUSES = [(301..302), (305..399), (401..404)].freeze #: Array[Range[Integer]]
-
# The store this app serves from. config.ru builds it and hands it in;
# nothing here reaches for a global to find one, which is what lets a
# test point the app at a throwaway database. Kept in Roda's own opts
@@ -152,13 +148,14 @@ module ProTacts
self.class.store
end
- # One step of a request, timed as a span on its Sentry transaction
- # so a slow screen names its slow step. The op is the step's whole
+ # One step of a request, timed as a span inside its request's span
+ # so a slow screen names its slow step. The name is the step's whole
# name; a span carries counts and never a value a person typed or a
- # card holds (config.ru's send_default_pii).
- #: [T] (String op) { (Sentry::Span?) -> T } -> T
- def span(op, &)
- Sentry.with_child_span(op:, description: op, &)
+ # card holds, since its attributes reach Sentry whatever
+ # send_default_pii says.
+ #: [T] (String name) { (OpenTelemetry::Trace::Span) -> T } -> T
+ def span(name, &)
+ Tracing::TRACER.in_span(name) { |step, _context| yield step }
end
end
end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 8620f29..1d3ed16 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -118,7 +118,7 @@ module ProTacts
# the walk stages as the import's original and strikes lines
# out of (Import::Monica).
bytes = span("import.monica") { Import::Monica.vcf(bytes) } if Import::Monica.export?(bytes)
- cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step&.set_data("cards", it.length) } }
+ cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step.set_attribute("cards", it.length) } }
rescue Import::Vcf::Invalid => error
return import_screen(notice: error.message)
end
@@ -250,7 +250,7 @@ module ProTacts
} if contact
arriving = import_contact(card, index)
- book = span("import.contacts") { |step| store.contacts.tap { step&.set_data("rows", it.length) } }
+ book = span("import.contacts") { |step| store.contacts.tap { step.set_attribute("rows", it.length) } }
matches = span("import.match") { Import::Match.candidates(arriving, book) }
target = into.nil? || into.empty? ? nil : store.contact(into)
# The row opened with no choice made is the first choice
@@ -618,9 +618,9 @@ module ProTacts
return nil if original.nil? || revised.nil?
span("import.staged") { |step|
- step&.set_data("bytes", original.bytesize + revised.bytesize)
+ step.set_attribute("bytes", original.bytesize + revised.bytesize)
cards = [Import::Vcf.cards(original), Import::Vcf.cards(revised)] #: [Array[VCard], Array[VCard]]
- step&.set_data("cards", cards[0].length)
+ step.set_attribute("cards", cards[0].length)
cards
}
end
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 25560ce..42f8ca4 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -40,17 +40,17 @@ module ProTacts
#: (String query) -> Admin::SearchResults
def search_results(query)
recent = span("search.contacts_by_recency") { |step|
- store.contacts_by_recency.tap { step&.set_data("rows", it.length) }
+ store.contacts_by_recency.tap { step.set_attribute("rows", it.length) }
}
if query.empty?
return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
end
- groups = span("search.all_groups") { |step| store.all_groups.tap { step&.set_data("rows", it.length) } }
+ groups = span("search.all_groups") { |step| store.all_groups.tap { step.set_attribute("rows", it.length) } }
span("search.match") { |step|
contacts = Admin::Search.contacts(recent, query:, groups:)
- step&.set_data("words", query.split.length)
- step&.set_data("contacts", contacts.length)
+ step.set_attribute("words", query.split.length)
+ step.set_attribute("contacts", contacts.length)
Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
}
end
diff --git a/sig/gems/opentelemetry.rbs b/sig/gems/opentelemetry.rbs
new file mode 100644
index 0000000..0888d9e
--- /dev/null
+++ b/sig/gems/opentelemetry.rbs
@@ -0,0 +1,60 @@
+# OpenTelemetry is reached from ProTacts::Tracing, which installs the SDK
+# and builds the request span's middleware, and from Web#span, which
+# times a step of a request inside that span.
+module OpenTelemetry
+ def self.tracer_provider: () -> Trace::TracerProvider
+
+ def self.propagation=: (untyped propagator) -> void
+
+ module Trace
+ class TracerProvider
+ def tracer: (String name) -> Tracer
+ end
+
+ class Tracer
+ def in_span: [T] (String name) { (Span span, untyped context) -> T } -> T
+ end
+
+ class Span
+ def set_attribute: (String key, Integer value) -> self
+ end
+ end
+
+ module SDK
+ def self.configure: () { (Configurator) -> void } -> void
+
+ class Configurator
+ def service_name=: (String name) -> void
+
+ def use: (String instrumentation_name, ?Hash[Symbol, untyped] config) -> void
+
+ def add_span_processor: (untyped processor) -> void
+ end
+
+ module Trace
+ module Export
+ class BatchSpanProcessor
+ def initialize: (Exporter::OTLP::Exporter exporter) -> void
+ end
+ end
+ end
+ end
+
+ module Exporter
+ module OTLP
+ class Exporter
+ def initialize: (endpoint: String) -> void
+ end
+ end
+ end
+
+ module Instrumentation
+ module Rack
+ class Instrumentation
+ def self.instance: () -> Instrumentation
+
+ def middleware_args: () -> Array[untyped]
+ end
+ end
+ end
+end
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 2747a73..2f94f01 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -3,13 +3,19 @@
# RefusalAlerts sends, the arrival messages dav.rb and store.rb send,
# and the photo decode refusal contact.rb captures. ExchangeLog tags the
# request's scope and reads whether anything was reported under it, and
-# the search times its steps as spans.
+# Tracing hands Sentry its spans and scrubs the transactions they become.
module Sentry
- # Yields nil outside a transaction, as under a nil DSN.
- def self.with_child_span: [T] (op: String, description: String) { (Span?) -> T } -> T
+ class TransactionEvent
+ def contexts: () -> Hash[Symbol, Hash[Symbol, untyped]]
+ end
+
+ module OpenTelemetry
+ class SpanProcessor
+ def self.instance: () -> SpanProcessor
+ end
- class Span
- def set_data: (String key, Integer value) -> void
+ class Propagator
+ end
end
def self.capture_message: (String message, ?level: Symbol, ?fingerprint: Array[String], ?tags: Hash[Symbol, String]) -> untyped
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index bc43c40..2485914 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -18,7 +18,7 @@ class AdminContactsPagesTest < Minitest::Test
include ThrowawayContacts
def app
- ProTacts::Web
+ traced(ProTacts::Web)
end
def setup
@@ -410,7 +410,7 @@ class AdminContactsPagesTest < Minitest::Test
transaction = transactions.first
spans = transaction.spans.select { it[:op].start_with?("search.") }
assert_equal %w[search.contacts_by_recency search.all_groups search.match search.render], spans.map { it[:op] }
- assert_equal({"words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
+ assert_equal({"otel.kind" => :internal, "words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
refute_includes JSON.generate(transaction.to_h), "lovelace"
end
ensure
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 742bf6c..c8c6f46 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -49,7 +49,7 @@ class AdminImportPagesTest < Minitest::Test
CARD
def app
- ProTacts::Web
+ traced(ProTacts::Web)
end
def setup
diff --git a/test/pro_tacts/test_tracing.rb b/test/pro_tacts/test_tracing.rb
new file mode 100644
index 0000000..ed20669
--- /dev/null
+++ b/test/pro_tacts/test_tracing.rb
@@ -0,0 +1,38 @@
+require_relative "../test_helper"
+
+require "rack/test"
+
+require "pro_tacts/web"
+
+# Which requests Sentry keeps a transaction for, now that Tracing's scrub
+# rather than trace_ignore_status_codes decides it.
+class TracingTest < Minitest::Test
+ include Rack::Test::Methods
+ include SentryTracing
+
+ def app
+ traced(ProTacts::Web)
+ end
+
+ def setup
+ setup_sentry_tracing
+ header "Remote-User", "test@example.com"
+ end
+
+ def teardown
+ teardown_sentry_test
+ end
+
+ def test_a_page_is_traced_by_its_route
+ get "/"
+
+ assert_equal ["GET /"], sentry_events.grep(Sentry::TransactionEvent).map(&:transaction)
+ end
+
+ def test_a_not_found_is_not_traced
+ get "/no-such-page"
+
+ assert_equal 404, last_response.status
+ assert_empty sentry_events.grep(Sentry::TransactionEvent)
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index e5888cf..c9f07a8 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -46,7 +46,16 @@ require "sentry/test_helper"
# re-opens the SDK when it finds it closed. Before minitest/autorun
# regardless, per the rule the header states, so direct single-file
# runs — where this file is the entry — keep the close after the tests.
-Sentry.init { |sentry| sentry.dsn = nil }
+# The instrumenter is config.ru's, so Sentry builds transactions from the
+# spans Tracing hands it, and Tracing is configured as config.ru does,
+# less the OTLP export.
+SENTRY_INIT = ->(sentry) {
+ sentry.dsn = nil
+ sentry.instrumenter = :otel
+}
+Sentry.init(&SENTRY_INIT)
+OpenTelemetry.logger = Logger.new($stderr, level: :warn)
+ProTacts::Tracing.configure(nil)
require "minitest/autorun"
@@ -64,7 +73,7 @@ ProTacts::Web.store = FixtureData.install(data_dir)
# store its birthday model what it cannot recompose.
module SentryMessages
def setup_sentry
- Sentry.init { |sentry| sentry.dsn = nil } unless Sentry.initialized?
+ Sentry.init(&SENTRY_INIT) unless Sentry.initialized?
setup_sentry_test
end
@@ -73,7 +82,7 @@ module SentryMessages
end
end
-# Tracing on for one test, under config.ru's statuses, so a request's
+# Tracing on for one test, scrubbed as config.ru scrubs, so a request's
# transaction and its spans land in sentry_events. Set on the test
# layer's configuration after setup rather than in setup_sentry_test's
# block, which configures the hub's base layer too — a layer teardown
@@ -85,7 +94,15 @@ module SentryTracing
def setup_sentry_tracing
setup_sentry
Sentry.configuration.traces_sample_rate = 1.0
- Sentry.configuration.trace_ignore_status_codes = ProTacts::Web::UNTRACED_STATUSES
+ Sentry.configuration.before_send_transaction = ->(event, _hint) { ProTacts::Tracing.scrub(event) }
+ end
+
+ # The app inside the request span's middleware, as config.ru mounts it.
+ def traced(app)
+ Rack::Builder.app do
+ use(*ProTacts::Tracing.middleware)
+ run app
+ end
end
end