Skip to content

Commit 6435ca4

Browse files
authored
Set span_name tag on Pyroscope profiles (#96)
* Enhance Pyroscope integration by customizing span processor and updating dependencies * Add type ignore comment for untyped pyroscope import * Adjust span name key and update test for Pyroscope instrumentation
1 parent bd069bc commit 6435ca4

File tree

3 files changed

+69
-16
lines changed

3 files changed

+69
-16
lines changed

microbootstrap/instruments/opentelemetry_instrument.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
11
from __future__ import annotations
22
import dataclasses
33
import os
4+
import threading
45
import typing
56

67
import pydantic
78
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
89
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] # noqa: TC002
910
from opentelemetry.sdk import resources
10-
from opentelemetry.sdk.trace import ReadableSpan
11+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
1112
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
1213
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
13-
from opentelemetry.trace import set_tracer_provider
14+
from opentelemetry.trace import format_span_id, set_tracer_provider
1415

1516
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
1617

1718

1819
try:
19-
from pyroscope.otel import PyroscopeSpanProcessor # type: ignore[import-untyped]
20+
import pyroscope # type: ignore[import-untyped]
2021
except ImportError: # pragma: no cover
21-
PyroscopeSpanProcessor = None
22+
pyroscope = None
2223

2324

2425
if typing.TYPE_CHECKING:
2526
import faststream
27+
from opentelemetry.context import Context
2628
from opentelemetry.metrics import Meter, MeterProvider
2729
from opentelemetry.trace import TracerProvider
2830

@@ -97,7 +99,7 @@ def bootstrap(self) -> None:
9799
resource: typing.Final = resources.Resource.create(attributes=attributes)
98100

99101
self.tracer_provider = SdkTracerProvider(resource=resource)
100-
if self.instrument_config.pyroscope_endpoint and PyroscopeSpanProcessor:
102+
if self.instrument_config.pyroscope_endpoint and pyroscope:
101103
self.tracer_provider.add_span_processor(PyroscopeSpanProcessor())
102104

103105
if self.instrument_config.service_debug:
@@ -129,3 +131,34 @@ def define_exclude_urls(self) -> list[str]:
129131
@classmethod
130132
def get_config_type(cls) -> type[OpentelemetryConfig]:
131133
return OpentelemetryConfig
134+
135+
136+
OTEL_PROFILE_ID_KEY: typing.Final = "pyroscope.profile.id"
137+
PYROSCOPE_SPAN_ID_KEY: typing.Final = "span_id"
138+
PYROSCOPE_SPAN_NAME_KEY: typing.Final = "span_name"
139+
140+
141+
def _is_root_span(span: ReadableSpan) -> bool:
142+
return span.parent is None or span.parent.is_remote
143+
144+
145+
# Extended `pyroscope-otel` span processor: https://github.com/grafana/otel-profiling-python/blob/990662d416943e992ab70036b35b27488c98336a/src/pyroscope/otel/__init__.py
146+
# Includes `span_name` to identify if it makes sense to go to profiles from traces.
147+
class PyroscopeSpanProcessor(SpanProcessor):
148+
def on_start(self, span: Span, parent_context: Context | None = None) -> None: # noqa: ARG002
149+
if _is_root_span(span):
150+
formatted_span_id = format_span_id(span.context.span_id)
151+
thread_id = threading.get_ident()
152+
153+
span.set_attribute(OTEL_PROFILE_ID_KEY, formatted_span_id)
154+
pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, formatted_span_id)
155+
pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name)
156+
157+
def on_end(self, span: ReadableSpan) -> None:
158+
if _is_root_span(span):
159+
thread_id = threading.get_ident()
160+
pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, format_span_id(span.context.span_id))
161+
pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name)
162+
163+
def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: ARG002 # pragma: no cover
164+
return True

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ dependencies = [
4242
"sentry-sdk>=2.7",
4343
"structlog>=24",
4444
"pyroscope-io; platform_system != 'Windows'",
45-
"pyroscope-otel; platform_system != 'Windows'",
4645
]
4746
dynamic = ["version"]
4847
authors = [{ name = "community-of-python" }]

tests/instruments/test_pyroscope.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1+
import typing
2+
from unittest import mock
3+
from unittest.mock import Mock
4+
5+
import fastapi
16
import pydantic
27
import pytest
8+
from fastapi.testclient import TestClient as FastAPITestClient
39

4-
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpentelemetryInstrument
10+
from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument
11+
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
512
from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
613

714

815
try:
9-
from pyroscope.otel import PyroscopeSpanProcessor # type: ignore[import-untyped]
16+
import pyroscope # type: ignore[import-untyped] # noqa: F401
1017
except ImportError: # pragma: no cover
1118
pytest.skip("pyroscope is not installed", allow_module_level=True)
1219

@@ -26,12 +33,26 @@ def test_not_ready(self) -> None:
2633
instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None))
2734
assert not instrument.is_ready()
2835

29-
def test_opentelemetry_includes_pyroscope(self) -> None:
30-
otel_instrument = OpentelemetryInstrument(
31-
OpentelemetryConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040"))
36+
def test_opentelemetry_includes_pyroscope_2(
37+
self, monkeypatch: pytest.MonkeyPatch, minimal_opentelemetry_config: OpentelemetryConfig
38+
) -> None:
39+
monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock())
40+
monkeypatch.setattr("pyroscope.add_thread_tag", add_thread_tag_mock := Mock())
41+
monkeypatch.setattr("pyroscope.remove_thread_tag", remove_thread_tag_mock := Mock())
42+
43+
minimal_opentelemetry_config.pyroscope_endpoint = pydantic.HttpUrl("http://localhost:4040")
44+
45+
opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config)
46+
opentelemetry_instrument.bootstrap()
47+
fastapi_application: typing.Final = opentelemetry_instrument.bootstrap_after(fastapi.FastAPI())
48+
49+
@fastapi_application.get("/test-handler")
50+
async def test_handler() -> None: ...
51+
52+
FastAPITestClient(app=fastapi_application).get("/test-handler")
53+
54+
assert (
55+
add_thread_tag_mock.mock_calls
56+
== remove_thread_tag_mock.mock_calls
57+
== [mock.call(mock.ANY, "span_id", mock.ANY), mock.call(mock.ANY, "span_name", "GET /test-handler")]
3258
)
33-
otel_instrument.bootstrap()
34-
assert PyroscopeSpanProcessor in {
35-
type(one_span_processor)
36-
for one_span_processor in otel_instrument.tracer_provider._active_span_processor._span_processors # noqa: SLF001
37-
}

0 commit comments

Comments
 (0)