Skip to content

Commit

Permalink
first cut for otel instrumentation
Browse files Browse the repository at this point in the history
  • Loading branch information
acer618 committed Nov 7, 2024
1 parent 92e8715 commit 9c4dafa
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 0 deletions.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
python_requires=">=3.7",
install_requires=[
'py_zipkin>=0.10.1',
'opentelemetry-sdk>=0.26.1',
],
keywords='zipkin',
classifiers=[
Expand Down
147 changes: 147 additions & 0 deletions swagger_zipkin/otel_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from __future__ import annotations

import os
import importlib

from typing import Any
from typing import TYPE_CHECKING
from typing import TypeVar

from opentelemetry import trace
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.trace.span import TraceFlags
from opentelemetry.trace.span import format_span_id
from opentelemetry.trace.span import format_trace_id

from typing_extensions import ParamSpec

from swagger_zipkin.decorate_client import Client
from swagger_zipkin.decorate_client import decorate_client
from swagger_zipkin.decorate_client import Resource


T = TypeVar('T', covariant=True)
P = ParamSpec('P')

if TYPE_CHECKING:
import pyramid.request.Request # type: ignore

tracer = trace.get_tracer("otel_decorator")


class OtelResourceDecorator:
"""A wrapper to the swagger resource.
:param resource: A resource object. eg. `client.pet`, `client.store`.
:type resource: :class:`swaggerpy.client.Resource` or :class:`bravado_core.resource.Resource`
"""

def __init__(self, resource: Client, client_identifier: str, smartstack_namespace: str) -> None:
self.resource = resource
self.client_identifier = client_identifier
self.smartstack_namespace = smartstack_namespace

def __getattr__(self, name: str) -> Resource:
return decorate_client(self.resource, self.with_headers, name)

def with_headers(self, call_name: str, *args: Any, **kwargs: Any) -> Any:
kwargs.setdefault('_request_options', {})
request_options: dict = kwargs['_request_options']
request_options.setdefault('headers', {})

request = get_pyramid_current_request()
http_route = getattr(request, "matched_route", "")
http_request_method = getattr(request, "method", "")

span_name = f"{http_request_method} {http_route}"
with tracer.start_as_current_span(
span_name, kind=trace.SpanKind.CLIENT
) as span:
span.set_attribute("url.path", getattr(request, "path", ""))
span.set_attribute("http.route", http_route)
span.set_attribute("http.request.method", http_request_method)

span.set_attribute("client.namespace", self.client_identifier)
span.set_attribute("peer.service", self.smartstack_namespace)
span.set_attribute("server.namespace", self.smartstack_namespace)

inject_otel_headers(kwargs, current_span=span)
inject_zipkin_headers(kwargs, current_span=span)

return getattr(self.resource, call_name)(*args, **kwargs)

def __dir__(self) -> list[str]:
return dir(self.resource)


class OtelClientDecorator:
"""A wrapper to swagger client (swagger-py or bravado) to pass on zipkin
headers to the service call.
Even though client is initialised once, all the calls made will have
independent spans.
:param client: Swagger Client
:type client: :class:`swaggerpy.client.SwaggerClient` or :class:`bravado.client.SwaggerClient`.
:param client_identifier: the name of the service that is using this
generated clientlib
:type client_identifier: string
:param smartstack_namespace: the smartstack name of the paasta instance
this generated clientlib is hitting
:type smartstack_namespace: string
"""

def __init__(self, client: Client, client_identifier: str, smartstack_namespace: str):
self._client = client
self.client_identifier = client_identifier
self.smartstack_namespace = smartstack_namespace

def __getattr__(self, name: str) -> Client:
return OtelResourceDecorator(
getattr(self._client, name),
client_identifier=self.client_identifier,
smartstack_namespace=self.smartstack_namespace,
)

def __dir__(self) -> list[str]:
return dir(self._client) # pragma: no cover


def inject_otel_headers(
kwargs: dict[str, Any], current_span: trace.Span
) -> None:
propagator = TraceContextTextMapPropagator()
carrier = kwargs['_request_options']["headers"]
propagator.inject(carrier=carrier, context=trace.set_span_in_context(current_span))


def inject_zipkin_headers(
kwargs: dict[str, Any], current_span: trace.Span
) -> None:
current_span_context = current_span.get_span_context()
kwargs["_request_options"]["headers"]["X-B3-TraceId"] = format_trace_id(
current_span_context.trace_id
)
kwargs["_request_options"]["headers"]["X-B3-SpanId"] = format_span_id(
current_span_context.span_id
)
parent_span = current_span.parent
if parent_span is not None:
kwargs["_request_options"]["headers"]["X-B3-ParentSpanId"] = format_span_id(
parent_span.span_id
)
kwargs["_request_options"]["headers"]["X-B3-Sampled"] = (
"1"
if (current_span_context.trace_flags & TraceFlags.SAMPLED == TraceFlags.SAMPLED)
else "0"
)
kwargs["_request_options"]["headers"]["X-B3-Flags"] = "0"


def get_pyramid_current_request() -> pyramid.request.Request | None:
try:
threadlocal = importlib.import_module("pyramid.threadlocal")
except ImportError:
return None

return threadlocal.get_current_request()
68 changes: 68 additions & 0 deletions tests/otel_decorator_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from unittest import mock

import os

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace.span import format_span_id
from opentelemetry.trace.span import format_trace_id

from swagger_zipkin.decorate_client import Client
from swagger_zipkin.otel_decorator import OtelClientDecorator

memory_exporter = InMemorySpanExporter()
span_processor = SimpleSpanProcessor(memory_exporter)
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(span_processor)

client_identifier = "test_client"
smartstack_namespace = "smartstack_namespace"
tracer = trace.get_tracer("otel_decorator")

def create_request_options(parent_span: trace.Span, exported_span: trace.Span):
trace_id = format_trace_id(parent_span.get_span_context().trace_id)
span_id = format_span_id(exported_span.get_span_context().span_id)
return {
'headers': {
'traceparent': f'00-{trace_id}-{span_id}-01',
'X-B3-TraceId': format_trace_id(parent_span.get_span_context().trace_id),
'X-B3-SpanId': format_span_id(exported_span.get_span_context().span_id),
'X-B3-ParentSpanId': format_span_id(parent_span.get_span_context().span_id),
'X-B3-Flags': '0',
'X-B3-Sampled': '1',
}
}

def test_client_request():
client = mock.Mock()
wrapped_client = OtelClientDecorator(
client, client_identifier=client_identifier, smartstack_namespace=smartstack_namespace
)

with tracer.start_as_current_span(
"parent_span", kind=trace.SpanKind.SERVER
) as parent_span:
resource = wrapped_client.resource
param = mock.Mock()
resource.operation(param)

assert len(memory_exporter.get_finished_spans()) == 1
exported_span = memory_exporter.get_finished_spans()[0]

client.resource.operation.assert_called_with(
param,
_request_options=create_request_options(parent_span, exported_span)
)

assert exported_span.attributes["url.path"] == ""
assert exported_span.attributes["http.request.method"] == ""
assert exported_span.attributes["http.route"] == ""
assert exported_span.attributes["client.namespace"] == client_identifier
assert exported_span.attributes["peer.service"] == smartstack_namespace
assert exported_span.attributes["server.namespace"] == smartstack_namespace
#assert exported_span.attributes["http.response.status_code"] == ""



0 comments on commit 9c4dafa

Please sign in to comment.