mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 17:47:28 +00:00
One emitter now sends both Python's and Rust's spans to the Jaeger agent. `init_tracer` sets the tracer up as before, with `initialize_tracer` (its sampler, remote or not, its throttler, its logging reporter, and the first-homeserver-wins guard), then swaps jaeger-client's reporter for one whose `report_span` converts the finished span into the Rust reporter's form, with the GIL held (a few attribute reads), and queues it. The encoding and the UDP send happen on the reporter thread, as for Rust's spans. Support for the external `rust_python_jaeger_reporter` package is removed. If tracing in Rust cannot start, jaeger-client's reporter stays. The conversion follows jaeger-client's `make_jaeger_batch`, so the datagrams are what jaeger-client sent, trace IDs from a remote context wider than 128 bits included: a test encodes spans of every shape (all tag types, logs, follows-from references, IDs at or above 2^63) both ways and compares the bytes. A failure to convert is logged with its traceback once, then at debug level. One jaeger `PrometheusMetricsFactory` serves the process, as each registers its metrics globally. jaeger-client's `reporter_*` options and metrics no longer apply: the docs say what the Rust reporter does instead. Tests run `init_tracer` and check that a Python span and a Rust span under it arrive in one datagram, and that `logging: true` keeps jaeger-client's logging reporter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4zxygfJC93SsugxskhoJG
990 lines
39 KiB
Python
990 lines
39 KiB
Python
#
|
|
# This file is licensed under the Affero General Public License (AGPL) version 3.
|
|
#
|
|
# Copyright (C) 2026 Element Creations Ltd
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# See the GNU Affero General Public License for more details:
|
|
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
|
#
|
|
|
|
"""Tests for reporting opentracing spans from Rust."""
|
|
|
|
import socket
|
|
from typing import Any, Callable, cast
|
|
from unittest import SkipTest
|
|
|
|
from twisted.internet.testing import MemoryReactor
|
|
|
|
from synapse.logging.context import LoggingContext
|
|
from synapse.logging.opentracing import (
|
|
_enable_rust_tracing,
|
|
start_active_span,
|
|
start_active_span_follows_from,
|
|
)
|
|
from synapse.logging.rust_tracing_metrics import rust_tracing_collector
|
|
from synapse.rest import admin
|
|
from synapse.rest.client import login, room, sync
|
|
from synapse.server import HomeServer
|
|
from synapse.storage.rust_pool import RustConnectionPool
|
|
from synapse.synapse_rust.opentracing import (
|
|
captured_span_for_test,
|
|
encode_python_span_for_test,
|
|
set_tracing_enabled_for_test,
|
|
span_in_blocking_task_for_test,
|
|
span_in_task_for_test,
|
|
span_on_thread_for_test,
|
|
)
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests.unittest import HomeserverTestCase, TestCase
|
|
|
|
try:
|
|
import jaeger_client
|
|
import opentracing
|
|
from jaeger_client.sampler import ConstSampler, ProbabilisticSampler
|
|
from jaeger_client.thrift_gen.agent.Agent import emitBatch_args
|
|
from jaeger_client.thrift_gen.jaeger import ttypes
|
|
from thrift.protocol.TCompactProtocol import ( # type: ignore[import-untyped]
|
|
TCompactProtocol,
|
|
TCompactProtocolFactory,
|
|
)
|
|
from thrift.transport.TTransport import ( # type: ignore[import-untyped]
|
|
TMemoryBuffer,
|
|
)
|
|
|
|
from synapse.logging.scopecontextmanager import LogContextScopeManager
|
|
except ImportError:
|
|
jaeger_client = None # type: ignore
|
|
LogContextScopeManager = None # type: ignore
|
|
|
|
|
|
def _context(span: "opentracing.Span") -> "jaeger_client.SpanContext":
|
|
return cast(jaeger_client.SpanContext, span.context)
|
|
|
|
|
|
def _make_tracer(sampled: bool = True) -> "jaeger_client.Tracer":
|
|
"""A Jaeger tracer that keeps its spans in memory and uses the logcontext
|
|
scope manager, like the real one."""
|
|
config = jaeger_client.config.Config(
|
|
config={}, service_name="test", scope_manager=LogContextScopeManager()
|
|
)
|
|
return config.create_tracer(
|
|
sampler=jaeger_client.ConstSampler(sampled),
|
|
reporter=jaeger_client.reporter.InMemoryReporter(),
|
|
)
|
|
|
|
|
|
class SpanCaptureTestCase(TestCase):
|
|
"""Rust reads the active span's context off the current logcontext when it
|
|
captures a handle for a task."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def setUp(self) -> None:
|
|
self._tracer = _make_tracer()
|
|
set_tracing_enabled_for_test(True)
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
|
|
def test_captures_active_span(self) -> None:
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("outer", tracer=self._tracer) as scope:
|
|
span = cast(jaeger_client.Span, scope.span)
|
|
span.set_baggage_item("user", "@alice:test")
|
|
|
|
captured = captured_span_for_test()
|
|
|
|
assert captured is not None
|
|
trace_id, span_id, flags, baggage = captured
|
|
self.assertEqual(trace_id, _context(span).trace_id)
|
|
self.assertEqual(span_id, _context(span).span_id)
|
|
self.assertEqual(flags, _context(span).flags)
|
|
self.assertEqual(flags & 1, 1)
|
|
self.assertEqual(baggage, {"user": "@alice:test"})
|
|
|
|
def test_captures_innermost_span(self) -> None:
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("outer", tracer=self._tracer) as outer:
|
|
with start_active_span("inner", tracer=self._tracer) as inner:
|
|
captured = captured_span_for_test()
|
|
|
|
# Back to the outer span once the inner one closes.
|
|
outer_captured = captured_span_for_test()
|
|
|
|
assert captured is not None
|
|
self.assertEqual(captured[1], _context(inner.span).span_id)
|
|
assert outer_captured is not None
|
|
self.assertEqual(outer_captured[1], _context(outer.span).span_id)
|
|
self.assertEqual(outer_captured[0], captured[0])
|
|
|
|
def test_unsampled_span_keeps_its_flags(self) -> None:
|
|
tracer = _make_tracer(sampled=False)
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("outer", tracer=tracer) as scope:
|
|
captured = captured_span_for_test()
|
|
|
|
assert captured is not None
|
|
self.assertEqual(captured[1], _context(scope.span).span_id)
|
|
self.assertEqual(captured[2] & 1, 0)
|
|
|
|
def test_nothing_without_a_span(self) -> None:
|
|
# The sentinel context.
|
|
self.assertIsNone(captured_span_for_test())
|
|
|
|
# A logcontext with no active span.
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
self.assertIsNone(captured_span_for_test())
|
|
|
|
def test_nothing_while_disabled(self) -> None:
|
|
set_tracing_enabled_for_test(False)
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("outer", tracer=self._tracer):
|
|
self.assertIsNone(captured_span_for_test())
|
|
|
|
|
|
class BlockingTaskSpanTestCase(HomeserverTestCase):
|
|
"""The `_blocking` entry points run their futures with the caller's span
|
|
as the parent, the same as the deferred ones."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
set_tracing_enabled_for_test(True)
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
|
|
def test_blocking_task_sees_the_span(self) -> None:
|
|
runtime = self.hs.get_rust_runtime()
|
|
tracer = _make_tracer()
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("outer", tracer=tracer) as scope:
|
|
span = span_in_blocking_task_for_test(runtime)
|
|
|
|
# No span once the scope has closed.
|
|
self.assertIsNone(span_in_blocking_task_for_test(runtime))
|
|
|
|
assert span is not None
|
|
self.assertEqual(span[0], _context(scope.span).trace_id)
|
|
self.assertEqual(span[1], _context(scope.span).span_id)
|
|
|
|
|
|
class EnableRustTracingTestCase(HomeserverTestCase):
|
|
"""`_enable_rust_tracing`, which `init_tracer` calls, hands the Python
|
|
tracer's settings to the Rust runtime."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
# Enabling tracing is process-wide: switch capture off again so it
|
|
# does not leak into other tests.
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
|
|
def _enable(self, sampler: "jaeger_client.sampler.Sampler") -> None:
|
|
config = jaeger_client.config.Config(
|
|
config={
|
|
"local_agent": {"reporting_host": "127.0.0.2", "reporting_port": 6000},
|
|
"tags": {"instance": "master"},
|
|
},
|
|
service_name="test service",
|
|
)
|
|
tracer = config.create_tracer(
|
|
sampler=sampler, reporter=jaeger_client.reporter.InMemoryReporter()
|
|
)
|
|
_enable_rust_tracing(self.hs, config, tracer)
|
|
# Detach this homeserver's reporter from the process-wide `tracing`
|
|
# subscriber, so later tests can attach theirs.
|
|
self.addCleanup(self.hs.get_rust_runtime().shutdown_tracing_for_test)
|
|
|
|
def test_enable(self) -> None:
|
|
runtime = self.hs.get_rust_runtime()
|
|
self.assertIsNone(runtime.tracing_config_for_test())
|
|
|
|
self._enable(ConstSampler(True))
|
|
settings = runtime.tracing_config_for_test()
|
|
assert settings is not None
|
|
service_name, process_tags, host, port, sampler = settings
|
|
self.assertEqual(service_name, "test service")
|
|
tags = dict(process_tags)
|
|
self.assertEqual(tags["instance"], "master")
|
|
self.assertIn("hostname", tags)
|
|
self.assertIn("jaeger.version", tags)
|
|
self.assertEqual((host, port), ("127.0.0.2", 6000))
|
|
self.assertEqual(sampler, "Const(true)")
|
|
|
|
# A second call is ignored rather than raising.
|
|
self._enable(ProbabilisticSampler(0.5))
|
|
settings = runtime.tracing_config_for_test()
|
|
assert settings is not None
|
|
self.assertEqual(settings[4], "Const(true)")
|
|
|
|
def test_probabilistic_sampler(self) -> None:
|
|
self._enable(ProbabilisticSampler(0.25))
|
|
settings = self.hs.get_rust_runtime().tracing_config_for_test()
|
|
assert settings is not None
|
|
self.assertEqual(settings[4], "Probabilistic(0.25)")
|
|
|
|
def test_unsupported_sampler(self) -> None:
|
|
self._enable(jaeger_client.sampler.RateLimitingSampler(10))
|
|
settings = self.hs.get_rust_runtime().tracing_config_for_test()
|
|
assert settings is not None
|
|
self.assertEqual(settings[4], "Never")
|
|
|
|
|
|
def _signed(value: int) -> int:
|
|
"""An ID as thrift's signed i64 carries it."""
|
|
return value - (1 << 64) if value >= (1 << 63) else value
|
|
|
|
|
|
def _decode_emit_batch(data: bytes) -> "ttypes.Batch":
|
|
"""Decode an `emitBatch` datagram the way the Jaeger agent does."""
|
|
protocol = TCompactProtocol(TMemoryBuffer(data))
|
|
name, _message_type, _seq_id = protocol.readMessageBegin()
|
|
assert name == "emitBatch", name
|
|
args = emitBatch_args()
|
|
args.read(protocol)
|
|
return args.batch
|
|
|
|
|
|
class ReporterRoundTripTestCase(HomeserverTestCase):
|
|
"""Spans reported from Rust reach the Jaeger agent's UDP port in a form
|
|
jaeger-client's own thrift code decodes."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
|
|
self.agent = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.addCleanup(self.agent.close)
|
|
self.agent.bind(("127.0.0.1", 0))
|
|
self.agent.settimeout(5)
|
|
port = self.agent.getsockname()[1]
|
|
|
|
config = jaeger_client.config.Config(
|
|
config={
|
|
"local_agent": {"reporting_host": "127.0.0.1", "reporting_port": port},
|
|
"tags": {"instance": "master"},
|
|
},
|
|
service_name="test service",
|
|
scope_manager=LogContextScopeManager(),
|
|
)
|
|
tracer = config.create_tracer(
|
|
sampler=ConstSampler(True),
|
|
reporter=jaeger_client.reporter.InMemoryReporter(),
|
|
)
|
|
_enable_rust_tracing(hs, config, tracer)
|
|
self.runtime = hs.get_rust_runtime()
|
|
# Detach this homeserver's reporter from the process-wide `tracing`
|
|
# subscriber, so later tests record nothing.
|
|
self.addCleanup(self.runtime.shutdown_tracing_for_test)
|
|
self.tracer = tracer
|
|
|
|
def _receive(self) -> "ttypes.Batch":
|
|
data, _ = self.agent.recvfrom(70_000)
|
|
return _decode_emit_batch(data)
|
|
|
|
def test_round_trip(self) -> None:
|
|
trace_id = (1 << 100) + 5
|
|
span_id = (1 << 63) + 7
|
|
self.runtime.report_span_for_test(
|
|
"db.query", trace_id, span_id, 42, 1, [("db.statement", "SELECT 1")]
|
|
)
|
|
self.assertTrue(self.runtime.flush_tracing_for_test(5))
|
|
|
|
batch = self._receive()
|
|
self.assertEqual(batch.process.serviceName, "test service")
|
|
process_tags = {tag.key: tag.vStr for tag in batch.process.tags}
|
|
self.assertEqual(process_tags["instance"], "master")
|
|
|
|
(span,) = batch.spans
|
|
self.assertEqual(span.operationName, "db.query")
|
|
# Thrift's i64s are signed, so the high span ID arrives negative,
|
|
# exactly as jaeger-client would send it.
|
|
self.assertEqual(span.traceIdLow, 5)
|
|
self.assertEqual(span.traceIdHigh, 1 << 36)
|
|
self.assertEqual(span.spanId, span_id - (1 << 64))
|
|
self.assertEqual(span.parentSpanId, 42)
|
|
self.assertEqual(span.flags, 1)
|
|
self.assertEqual(span.duration, 1000)
|
|
self.assertGreater(span.startTime, 0)
|
|
self.assertEqual(
|
|
[(tag.key, tag.vType, tag.vStr) for tag in span.tags],
|
|
[("db.statement", ttypes.TagType.STRING, "SELECT 1")],
|
|
)
|
|
|
|
def _spans_under_python_span(
|
|
self, run: "Callable[[], None]"
|
|
) -> "tuple[jaeger_client.SpanContext, list[ttypes.Span]]":
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("python", tracer=self.tracer) as scope:
|
|
run()
|
|
self.assertTrue(self.runtime.flush_tracing_for_test(5))
|
|
return _context(scope.span), self._receive().spans
|
|
|
|
def test_task_span_has_the_python_parent(self) -> None:
|
|
parent, spans = self._spans_under_python_span(
|
|
lambda: span_in_task_for_test(self.runtime, "db.persist_events")
|
|
)
|
|
|
|
(span,) = spans
|
|
self.assertEqual(span.operationName, "db.persist_events")
|
|
self.assertEqual(span.traceIdLow, _signed(parent.trace_id))
|
|
self.assertEqual(span.parentSpanId, _signed(parent.span_id))
|
|
self.assertEqual(span.flags, parent.flags)
|
|
self.assertEqual(
|
|
[(tag.key, tag.vLong) for tag in span.tags], [("db.queued", 3)]
|
|
)
|
|
(log,) = span.logs
|
|
self.assertEqual(
|
|
[(field.key, field.vStr) for field in log.fields], [("message", "commit")]
|
|
)
|
|
|
|
def test_thread_span_has_the_python_parent(self) -> None:
|
|
parent, spans = self._spans_under_python_span(
|
|
lambda: span_on_thread_for_test("state.resolve")
|
|
)
|
|
|
|
(span,) = spans
|
|
self.assertEqual(span.operationName, "state.resolve")
|
|
self.assertEqual(span.traceIdLow, _signed(parent.trace_id))
|
|
self.assertEqual(span.parentSpanId, _signed(parent.span_id))
|
|
|
|
def test_unsampled_python_parent(self) -> None:
|
|
unsampled = _make_tracer(sampled=False)
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("python", tracer=unsampled):
|
|
span_in_task_for_test(self.runtime, "not sampled")
|
|
# A sampled span after it, so there is something to wait for.
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("python", tracer=self.tracer):
|
|
span_in_task_for_test(self.runtime, "sampled")
|
|
self.assertTrue(self.runtime.flush_tracing_for_test(5))
|
|
|
|
self.assertEqual(
|
|
[span.operationName for span in self._receive().spans], ["sampled"]
|
|
)
|
|
|
|
def test_metrics(self) -> None:
|
|
for span_id in range(1, 4):
|
|
self.runtime.report_span_for_test("op", 1, span_id, 0, 1, [])
|
|
self.assertTrue(self.runtime.flush_tracing_for_test(5))
|
|
self.assertEqual(len(self._receive().spans), 3)
|
|
|
|
self.assertEqual(self.runtime.tracing_stats(), (0, 3, 1, 0, 0, 0, 0))
|
|
|
|
metrics = {
|
|
(metric.name, sample.labels.get("reason")): sample.value
|
|
for metric in rust_tracing_collector.collect()
|
|
for sample in metric.samples
|
|
if sample.labels.get("server_name") == self.hs.hostname
|
|
}
|
|
self.assertEqual(metrics[("synapse_rust_tracing_spans_sent", None)], 3)
|
|
self.assertEqual(metrics[("synapse_rust_tracing_batches_sent", None)], 1)
|
|
self.assertEqual(
|
|
metrics[("synapse_rust_tracing_spans_dropped", "queue_full")], 0
|
|
)
|
|
|
|
def test_shutdown_sends_queued_spans(self) -> None:
|
|
self.runtime.report_span_for_test("op", 1, 2, 0, 1, [])
|
|
self.runtime.shutdown_tracing_for_test()
|
|
|
|
self.assertEqual(len(self._receive().spans), 1)
|
|
|
|
# Spans reported after shutdown are dropped and counted.
|
|
self.runtime.report_span_for_test("op", 1, 3, 0, 1, [])
|
|
stats = self.runtime.tracing_stats()
|
|
assert stats is not None
|
|
self.assertEqual(stats[5], 1)
|
|
|
|
|
|
class _AgentTestCase(HomeserverTestCase):
|
|
"""Tracing on, as `init_tracer` sets it up: a sampling Jaeger tracer as the
|
|
global tracer, and Rust reporting to a UDP socket the test reads."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
|
|
self.agent = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.addCleanup(self.agent.close)
|
|
self.agent.bind(("127.0.0.1", 0))
|
|
port = self.agent.getsockname()[1]
|
|
|
|
config = jaeger_client.config.Config(
|
|
config={
|
|
"local_agent": {"reporting_host": "127.0.0.1", "reporting_port": port}
|
|
},
|
|
service_name="test service",
|
|
scope_manager=LogContextScopeManager(),
|
|
)
|
|
self.python_spans = jaeger_client.reporter.InMemoryReporter()
|
|
tracer = config.create_tracer(
|
|
sampler=ConstSampler(True), reporter=self.python_spans
|
|
)
|
|
self.prepare_before_tracing()
|
|
|
|
# Synapse's own spans, the request's and the persist queue's, come
|
|
# from the global tracer: make it this one, as `init_tracer` does.
|
|
previous_tracer = opentracing.global_tracer()
|
|
opentracing.set_global_tracer(tracer)
|
|
self.addCleanup(opentracing.set_global_tracer, previous_tracer)
|
|
|
|
_enable_rust_tracing(hs, config, tracer)
|
|
self.runtime = hs.get_rust_runtime()
|
|
self.addCleanup(self.runtime.shutdown_tracing_for_test)
|
|
|
|
def prepare_before_tracing(self) -> None:
|
|
"""Set-up that should not be traced."""
|
|
|
|
def _received_spans(self) -> "list[ttypes.Span]":
|
|
"""Every span sent so far."""
|
|
self.assertTrue(self.runtime.flush_tracing_for_test(5))
|
|
spans: list[ttypes.Span] = []
|
|
self.agent.settimeout(0.2)
|
|
while True:
|
|
try:
|
|
data, _ = self.agent.recvfrom(70_000)
|
|
except TimeoutError:
|
|
return spans
|
|
spans.extend(_decode_emit_batch(data).spans)
|
|
|
|
def _python_spans_by_id(self) -> "dict[int, jaeger_client.Span]":
|
|
"""The Python spans reported so far, by thrift's signed span ID."""
|
|
return {_signed(span.span_id): span for span in self.python_spans.get_spans()}
|
|
|
|
def _python_ancestors(
|
|
self, span: "ttypes.Span", python_by_id: "dict[int, jaeger_client.Span]"
|
|
) -> list[str]:
|
|
"""The names of the Python spans above the Rust span `span`, nearest
|
|
first."""
|
|
ancestors = []
|
|
python_span = python_by_id.get(span.parentSpanId)
|
|
while python_span is not None:
|
|
ancestors.append(python_span.operation_name)
|
|
parent_id = python_span.parent_id
|
|
python_span = python_by_id.get(_signed(parent_id)) if parent_id else None
|
|
return ancestors
|
|
|
|
|
|
class PersistTracingTestCase(_AgentTestCase):
|
|
"""Events persisted by the Rust persister report the persister's steps,
|
|
with the write transaction's `db.*` spans under them.
|
|
|
|
Needs the native Rust Postgres driver:
|
|
|
|
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.logging.test_rust_tracing
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def default_config(self) -> dict[str, Any]:
|
|
config = super().default_config()
|
|
config.setdefault("experimental_features", {})["rust_event_persistence"] = (
|
|
"full"
|
|
)
|
|
return config
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
hs = self.setup_test_homeserver()
|
|
if not isinstance(
|
|
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
|
|
):
|
|
raise SkipTest("needs the native Rust Postgres driver")
|
|
persistence = hs.get_storage_controllers().persistence
|
|
if persistence is None or persistence._rust_persister is None:
|
|
raise SkipTest("needs the Rust persister")
|
|
return hs
|
|
|
|
def prepare_before_tracing(self) -> None:
|
|
self.user = self.register_user("user", "pass")
|
|
self.token = self.login("user", "pass")
|
|
self.room_id = self.helper.create_room_as(self.user, tok=self.token)
|
|
|
|
def test_persist_span_tree(self) -> None:
|
|
self.helper.send(self.room_id, "hello", tok=self.token)
|
|
|
|
spans = self._received_spans()
|
|
by_id = {span.spanId: span for span in spans}
|
|
|
|
def parent(span: "ttypes.Span") -> "ttypes.Span | None":
|
|
return by_id.get(span.parentSpanId)
|
|
|
|
def tags(span: "ttypes.Span") -> dict[str, Any]:
|
|
return {
|
|
tag.key: tag.vStr if tag.vType == ttypes.TagType.STRING else tag.vLong
|
|
for tag in span.tags
|
|
}
|
|
|
|
def named(name: str) -> "list[ttypes.Span]":
|
|
return [s for s in spans if s.operationName == name]
|
|
|
|
# The Rust persist hangs off the Python span of the persist queue,
|
|
# in the same trace as the request.
|
|
python_by_id = self._python_spans_by_id()
|
|
(batch_next, _done) = named("persist.batch_next")
|
|
python_parent = python_by_id.get(batch_next.parentSpanId)
|
|
assert python_parent is not None, (batch_next, list(python_by_id.values()))
|
|
python_trace = _signed(python_parent.trace_id)
|
|
# The active Python span may be one the persist holds open, such as
|
|
# the lock it waits for: walk up to the persist queue's span.
|
|
self.assertIn(
|
|
"persist_event_batch", self._python_ancestors(batch_next, python_by_id)
|
|
)
|
|
for span in spans:
|
|
if _in_subtree(span, batch_next, by_id):
|
|
self.assertEqual(span.traceIdLow, python_trace, span)
|
|
|
|
# persist.batch_next → persist.prepare_chunk and persist.write_chunk
|
|
(prepare_chunk,) = named("persist.prepare_chunk")
|
|
(write_chunk,) = named("persist.write_chunk")
|
|
self.assertEqual(prepare_chunk.parentSpanId, batch_next.spanId)
|
|
self.assertEqual(write_chunk.parentSpanId, batch_next.spanId)
|
|
self.assertEqual(tags(write_chunk)["room_id"], self.room_id)
|
|
for stage in ("persist.state_delta", "persist.chain_cover"):
|
|
(stage_span,) = named(stage)
|
|
self.assertEqual(stage_span.parentSpanId, prepare_chunk.spanId)
|
|
|
|
# persist.write_chunk → db.persist_events → db.connection → db.txn
|
|
(interaction,) = named("db.persist_events")
|
|
self.assertEqual(interaction.parentSpanId, write_chunk.spanId)
|
|
txns = []
|
|
for txn in named("db.txn"):
|
|
connection = parent(txn)
|
|
if connection is not None and connection.parentSpanId == interaction.spanId:
|
|
txns.append(txn)
|
|
self.assertEqual(len(txns), 1)
|
|
(txn,) = txns
|
|
self.assertEqual(tags(txn)["db.txn_desc"], "persist_events")
|
|
|
|
# The transaction's writes go out queued, with the statements that
|
|
# read and with the COMMIT.
|
|
queries = [s for s in named("db.query") if s.parentSpanId == txn.spanId]
|
|
self.assertGreater(len(queries), 0)
|
|
self.assertTrue(
|
|
any(tags(query).get("db.queued", 0) > 0 for query in queries), queries
|
|
)
|
|
self.assertIn("COMMIT", [tags(query).get("db.statement") for query in queries])
|
|
|
|
# The transaction's steps are children of the transaction.
|
|
steps = {s.operationName for s in spans if s.parentSpanId == txn.spanId} - {
|
|
"db.query",
|
|
"db.rollback",
|
|
}
|
|
self.assertIn("persist.txn.read", steps)
|
|
self.assertIn("persist.txn.store_events", steps)
|
|
|
|
|
|
def _in_subtree(
|
|
span: "ttypes.Span", root: "ttypes.Span", by_id: "dict[int, ttypes.Span]"
|
|
) -> bool:
|
|
while True:
|
|
if span.spanId == root.spanId:
|
|
return True
|
|
if span.parentSpanId not in by_id:
|
|
return False
|
|
span = by_id[span.parentSpanId]
|
|
|
|
|
|
class SyncTracingTestCase(_AgentTestCase):
|
|
"""A `/sync` whose rooms Rust computes reports a span per room under the
|
|
request's Python span.
|
|
|
|
Needs the native Rust Postgres driver:
|
|
|
|
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.logging.test_rust_tracing
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
sync.register_servlets,
|
|
]
|
|
|
|
def default_config(self) -> dict[str, Any]:
|
|
config = super().default_config()
|
|
config.setdefault("experimental_features", {})["rust_sync"] = "on"
|
|
return config
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
hs = self.setup_test_homeserver()
|
|
if not isinstance(
|
|
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
|
|
):
|
|
raise SkipTest("needs the native Rust Postgres driver")
|
|
if hs.config.experimental.rust_sync != "on":
|
|
raise SkipTest("SYNAPSE_TEST_RUST_SYNC overrides the config")
|
|
return hs
|
|
|
|
def prepare_before_tracing(self) -> None:
|
|
self.user = self.register_user("user", "pass")
|
|
self.token = self.login("user", "pass")
|
|
self.rooms = [
|
|
self.helper.create_room_as(self.user, tok=self.token) for _ in range(2)
|
|
]
|
|
|
|
def test_sync_span_tree(self) -> None:
|
|
channel = self.make_request("GET", "/sync", access_token=self.token)
|
|
self.assertEqual(channel.code, 200, channel.json_body)
|
|
|
|
spans = self._received_spans()
|
|
|
|
def named(name: str) -> "list[ttypes.Span]":
|
|
return [s for s in spans if s.operationName == name]
|
|
|
|
# Under the request's Python span.
|
|
python_by_id = self._python_spans_by_id()
|
|
(changes,) = named("sync.room_changes")
|
|
(entries,) = named("sync.room_entries")
|
|
for span in (changes, entries):
|
|
self.assertIn("SyncRestServlet", self._python_ancestors(span, python_by_id))
|
|
|
|
rooms = named("sync.room")
|
|
self.assertEqual(len(rooms), 2)
|
|
room_ids = {
|
|
next(t.vStr for t in room_span.tags if t.key == "room_id")
|
|
for room_span in rooms
|
|
}
|
|
self.assertEqual(room_ids, set(self.rooms))
|
|
for room_span in rooms:
|
|
self.assertEqual(room_span.parentSpanId, entries.spanId)
|
|
self.assertEqual(
|
|
next(t.vStr for t in room_span.tags if t.key == "outcome"), "entry"
|
|
)
|
|
|
|
# The rooms' reads nest under them.
|
|
room_span_ids = {room_span.spanId for room_span in rooms}
|
|
self.assertTrue(
|
|
any(
|
|
s.operationName == "db.connection" and s.parentSpanId in room_span_ids
|
|
for s in spans
|
|
),
|
|
[s.operationName for s in spans],
|
|
)
|
|
|
|
(encode,) = named("sync.encode")
|
|
self.assertEqual(
|
|
next(t.vLong for t in encode.tags if t.key == "rooms"), 2, encode
|
|
)
|
|
# Encoded on the reactor, under the request's Python span.
|
|
self.assertEqual(encode.traceIdLow, entries.traceIdLow)
|
|
self.assertIn("SyncRestServlet", self._python_ancestors(encode, python_by_id))
|
|
|
|
|
|
class EventLoadTracingTestCase(_AgentTestCase):
|
|
"""An event load in Rust reports `events.load` under the caller's Python
|
|
span, with the event fetched from the database.
|
|
|
|
Needs the native Rust Postgres driver:
|
|
|
|
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.logging.test_rust_tracing
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
hs = self.setup_test_homeserver()
|
|
if hs.get_datastores().main._rust_event_loader is None:
|
|
raise SkipTest("needs the native Rust Postgres driver")
|
|
return hs
|
|
|
|
def prepare_before_tracing(self) -> None:
|
|
self.user = self.register_user("user", "pass")
|
|
self.token = self.login("user", "pass")
|
|
room_id = self.helper.create_room_as(self.user, tok=self.token)
|
|
self.event_id = self.helper.send(room_id, "hello", tok=self.token)["event_id"]
|
|
|
|
def test_event_load_span(self) -> None:
|
|
store = self.hs.get_datastores().main
|
|
# Out of the cache, so that the load reads the database.
|
|
store._invalidate_local_get_event_cache(self.event_id)
|
|
|
|
tracer = opentracing.global_tracer()
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("load", tracer=tracer) as scope:
|
|
self.get_success(store.get_event(self.event_id))
|
|
|
|
spans = self._received_spans()
|
|
trace_id = _signed(_context(scope.span).trace_id)
|
|
loads = [
|
|
s
|
|
for s in spans
|
|
if s.operationName == "events.load" and s.traceIdLow == trace_id
|
|
]
|
|
self.assertEqual(len(loads), 1, [s.operationName for s in spans])
|
|
(load,) = loads
|
|
# Under the caller's span, through the store's own traced calls.
|
|
ancestors = self._python_ancestors(load, self._python_spans_by_id())
|
|
self.assertEqual(ancestors[-1], "load", ancestors)
|
|
tags = {t.key: t.vLong for t in load.tags if t.vType == ttypes.TagType.LONG}
|
|
self.assertEqual(tags["fetched"], 1)
|
|
self.assertEqual(tags["events"], 1)
|
|
|
|
# Its reads nest under it.
|
|
self.assertTrue(
|
|
any(
|
|
s.operationName == "db.connection" and s.parentSpanId == load.spanId
|
|
for s in spans
|
|
)
|
|
)
|
|
|
|
|
|
class SlidingSyncTracingTestCase(_AgentTestCase):
|
|
"""A sliding sync request Rust computes reports its steps and a span per
|
|
room under the request's Python span.
|
|
|
|
Needs the native Rust Postgres driver:
|
|
|
|
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.logging.test_rust_tracing
|
|
"""
|
|
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
sync.register_servlets,
|
|
]
|
|
|
|
def default_config(self) -> dict[str, Any]:
|
|
config = super().default_config()
|
|
config.setdefault("experimental_features", {})["rust_sliding_sync"] = "on"
|
|
return config
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
hs = self.setup_test_homeserver()
|
|
if hs.get_sliding_sync_handler()._rust_sliding_sync is None:
|
|
raise SkipTest("needs the Rust sliding sync handler")
|
|
return hs
|
|
|
|
def prepare_before_tracing(self) -> None:
|
|
self.user = self.register_user("user", "pass")
|
|
self.token = self.login("user", "pass")
|
|
self.rooms = [
|
|
self.helper.create_room_as(self.user, tok=self.token) for _ in range(2)
|
|
]
|
|
|
|
def test_sliding_sync_span_tree(self) -> None:
|
|
channel = self.make_request(
|
|
"POST",
|
|
"/_matrix/client/unstable/org.matrix.simplified_msc3575/sync",
|
|
{
|
|
"lists": {
|
|
"all": {
|
|
"ranges": [[0, 9]],
|
|
"required_state": [],
|
|
"timeline_limit": 1,
|
|
}
|
|
}
|
|
},
|
|
access_token=self.token,
|
|
)
|
|
self.assertEqual(channel.code, 200, channel.json_body)
|
|
|
|
spans = self._received_spans()
|
|
python_by_id = self._python_spans_by_id()
|
|
|
|
def named(name: str) -> "list[ttypes.Span]":
|
|
return [s for s in spans if s.operationName == name]
|
|
|
|
steps = [
|
|
"sliding_sync.load_connection_state",
|
|
"sliding_sync.interested_rooms",
|
|
"sliding_sync.room_entries",
|
|
"sliding_sync.finish_request",
|
|
"sliding_sync.encode",
|
|
]
|
|
for step in steps:
|
|
(span,) = named(step)
|
|
self.assertIn(
|
|
"SlidingSyncRestServlet", self._python_ancestors(span, python_by_id)
|
|
)
|
|
|
|
(entries,) = named("sliding_sync.room_entries")
|
|
rooms = named("sliding_sync.room")
|
|
self.assertEqual(len(rooms), 2)
|
|
self.assertEqual(
|
|
{next(t.vStr for t in s.tags if t.key == "room_id") for s in rooms},
|
|
set(self.rooms),
|
|
)
|
|
for room_span in rooms:
|
|
self.assertEqual(room_span.parentSpanId, entries.spanId)
|
|
self.assertEqual(
|
|
next(t.vStr for t in room_span.tags if t.key == "outcome"), "entry"
|
|
)
|
|
|
|
# The rooms' reads nest under them.
|
|
room_span_ids = {room_span.spanId for room_span in rooms}
|
|
self.assertTrue(
|
|
any(
|
|
s.operationName == "db.connection" and s.parentSpanId in room_span_ids
|
|
for s in spans
|
|
),
|
|
[s.operationName for s in spans],
|
|
)
|
|
|
|
|
|
def _python_encoding(
|
|
span: "jaeger_client.Span", service_name: str, process_tags: dict[str, str]
|
|
) -> bytes:
|
|
"""The `emitBatch` message jaeger-client's own reporter sends for `span`,
|
|
with sequence ID 1."""
|
|
from jaeger_client import thrift as jaeger_thrift
|
|
from jaeger_client.thrift_gen.agent import Agent
|
|
|
|
process = jaeger_thrift.make_process(service_name, process_tags, 1024)
|
|
batch = jaeger_thrift.make_jaeger_batch([span], process)
|
|
buffer = TMemoryBuffer()
|
|
client = Agent.Client.__new__(Agent.Client)
|
|
client._transport = buffer
|
|
client._oprot_factory = TCompactProtocolFactory()
|
|
client._seqid = 0
|
|
client.emitBatch(batch)
|
|
return buffer.getvalue()
|
|
|
|
|
|
class PythonSpanEncodingTestCase(TestCase):
|
|
"""The Rust reporter sends Python's spans exactly as jaeger-client's own
|
|
reporter does."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def test_same_bytes(self) -> None:
|
|
reporter = jaeger_client.reporter.InMemoryReporter()
|
|
config = jaeger_client.config.Config(
|
|
config={}, service_name="test", scope_manager=LogContextScopeManager()
|
|
)
|
|
tracer = config.create_tracer(sampler=ConstSampler(True), reporter=reporter)
|
|
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("root", tracer=tracer) as root:
|
|
root.span.set_tag("string", "value")
|
|
root.span.set_tag("number", 12)
|
|
root.span.set_tag("ratio", 0.25)
|
|
root.span.set_tag("flag", True)
|
|
root.span.log_kv({"message": "hello", "count": 3})
|
|
with start_active_span("child", tracer=tracer):
|
|
pass
|
|
with start_active_span_follows_from(
|
|
"follower", [root.span.context], tracer=tracer
|
|
):
|
|
pass
|
|
|
|
# Also IDs at and above 2^63, which thrift carries negative.
|
|
big = tracer.start_span("big ids")
|
|
big_context = cast(jaeger_client.SpanContext, big.context)
|
|
big_context.span_id = (1 << 63) + 5
|
|
big_context.trace_id = (1 << 100) + 7
|
|
big.finish()
|
|
|
|
# A trace ID from a remote context can be wider than 128 bits:
|
|
# jaeger-client sends the low 128.
|
|
wide = tracer.start_span("wide trace id")
|
|
cast(jaeger_client.SpanContext, wide.context).trace_id = (1 << 130) + 9
|
|
wide.finish()
|
|
|
|
spans = reporter.get_spans()
|
|
self.assertEqual(len(spans), 5)
|
|
process_tags = {"hostname": "host", "jaeger.version": "Python-4.8.0"}
|
|
for span in spans:
|
|
rust = encode_python_span_for_test(
|
|
span, "test service", list(process_tags.items())
|
|
)
|
|
python = _python_encoding(span, "test service", process_tags)
|
|
self.assertEqual(rust, python, span.operation_name)
|
|
|
|
|
|
class InitTracerTestCase(HomeserverTestCase):
|
|
"""`init_tracer` reports the Python tracer's spans through the Rust
|
|
reporter."""
|
|
|
|
if jaeger_client is None:
|
|
skip = "Requires jaeger_client" # type: ignore[unreachable]
|
|
|
|
def test_python_spans_go_through_rust(self) -> None:
|
|
import synapse.logging.opentracing as synapse_opentracing
|
|
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
previous_tracer = opentracing.global_tracer()
|
|
self.addCleanup(opentracing.set_global_tracer, previous_tracer)
|
|
|
|
agent = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.addCleanup(agent.close)
|
|
agent.bind(("127.0.0.1", 0))
|
|
agent.settimeout(5)
|
|
port = agent.getsockname()[1]
|
|
|
|
tracing_config = self.hs.config.tracing
|
|
tracing_config.opentracer_enabled = True
|
|
tracing_config.opentracer_whitelist = []
|
|
tracing_config.jaeger_config = {
|
|
"sampler": {"type": "const", "param": 1},
|
|
"local_agent": {"reporting_host": "127.0.0.1", "reporting_port": port},
|
|
}
|
|
# `initialize_tracer` initialises jaeger-client once per process.
|
|
self.addCleanup(setattr, jaeger_client.Config, "_initialized", False)
|
|
synapse_opentracing.init_tracer(self.hs)
|
|
runtime = self.hs.get_rust_runtime()
|
|
self.addCleanup(runtime.shutdown_tracing_for_test)
|
|
|
|
tracer = cast(jaeger_client.Tracer, opentracing.global_tracer())
|
|
self.assertIsInstance(tracer.reporter, synapse_opentracing._RustSpanReporter)
|
|
with LoggingContext(name="ctx", server_name="test_server"):
|
|
with start_active_span("from python", tracer=tracer) as scope:
|
|
span_in_task_for_test(runtime, "from rust")
|
|
self.assertTrue(runtime.flush_tracing_for_test(5))
|
|
|
|
data, _ = agent.recvfrom(70_000)
|
|
batch = _decode_emit_batch(data)
|
|
names = {span.operationName for span in batch.spans}
|
|
self.assertEqual(names, {"from python", "from rust"})
|
|
self.assertEqual(batch.process.serviceName, tracer.service_name)
|
|
(rust_span,) = [s for s in batch.spans if s.operationName == "from rust"]
|
|
self.assertEqual(rust_span.parentSpanId, _signed(_context(scope.span).span_id))
|
|
|
|
def test_logging_reporter_is_kept(self) -> None:
|
|
"""With `logging: true`, spans still go to jaeger-client's logging
|
|
reporter too. A second `init_tracer` in the process is fine."""
|
|
from jaeger_client.reporter import CompositeReporter, LoggingReporter
|
|
|
|
import synapse.logging.opentracing as synapse_opentracing
|
|
|
|
self.addCleanup(set_tracing_enabled_for_test, False)
|
|
previous_tracer = opentracing.global_tracer()
|
|
self.addCleanup(opentracing.set_global_tracer, previous_tracer)
|
|
self.addCleanup(setattr, jaeger_client.Config, "_initialized", False)
|
|
|
|
tracing_config = self.hs.config.tracing
|
|
tracing_config.opentracer_enabled = True
|
|
tracing_config.opentracer_whitelist = []
|
|
tracing_config.jaeger_config = {
|
|
"sampler": {"type": "const", "param": 0},
|
|
"logging": True,
|
|
}
|
|
synapse_opentracing.init_tracer(self.hs)
|
|
self.addCleanup(self.hs.get_rust_runtime().shutdown_tracing_for_test)
|
|
|
|
tracer = cast(jaeger_client.Tracer, opentracing.global_tracer())
|
|
reporter = tracer.reporter
|
|
assert isinstance(reporter, CompositeReporter)
|
|
first, second = reporter.reporters
|
|
self.assertIsInstance(first, synapse_opentracing._RustSpanReporter)
|
|
self.assertIsInstance(second, LoggingReporter)
|