-
Notifications
You must be signed in to change notification settings - Fork 9
Add privacy-safe configurable OpenTelemetry tracing #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,47 +1,150 @@ | ||
| """OpenTelemetry setup for CodeAlive MCP server. | ||
|
|
||
| Initialises a ``TracerProvider`` with an OTLP/HTTP exporter when the | ||
| ``OTEL_EXPORTER_OTLP_ENDPOINT`` env var is set. Otherwise tracing is | ||
| configured as a no-op so the rest of the code can call ``trace.get_tracer()`` | ||
| unconditionally. | ||
| Initialises a ``TracerProvider`` with an OTLP/HTTP exporter when either the | ||
| generic or traces-specific OTLP endpoint is configured. Otherwise tracing is | ||
| configured without an exporter so the rest of the code can call | ||
| ``trace.get_tracer()`` unconditionally. | ||
|
|
||
| HTTPX client instrumentation is always enabled so outbound HTTP calls | ||
| automatically get ``traceparent`` headers injected. | ||
| Starlette and HTTPX instrumentation connect inbound MCP requests to outbound | ||
| CodeAlive API calls without recording request or response bodies. | ||
| """ | ||
|
|
||
| import atexit | ||
| import os | ||
| from collections.abc import Sequence | ||
|
|
||
| from loguru import logger | ||
| from opentelemetry import trace | ||
| from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor | ||
| from opentelemetry.instrumentation.starlette import StarletteInstrumentor | ||
| from opentelemetry.sdk.resources import Resource | ||
| from opentelemetry.sdk.trace import TracerProvider | ||
| from opentelemetry.sdk.trace import Event, ReadableSpan, TracerProvider | ||
| from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult | ||
| from opentelemetry.trace import Status | ||
|
|
||
| _SERVICE_NAME = "codealive-mcp" | ||
|
|
||
| _SENSITIVE_ATTRIBUTE_PREFIXES = ( | ||
| "enduser.", | ||
| "http.request.header.", | ||
| "http.response.header.", | ||
| ) | ||
| _SENSITIVE_ATTRIBUTES = { | ||
| "client.address", | ||
| "http.url", | ||
| "mcp.session.id", | ||
| "mcp.resource.uri", | ||
| "network.peer.address", | ||
| "url.full", | ||
| "url.query", | ||
| "user_agent.original", | ||
|
Comment on lines
+38
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an OTLP endpoint is configured and the server receives HTTP traffic, the pinned Starlette/ASGI instrumentation can emit the legacy Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
|
|
||
| class _SanitizingSpanExporter(SpanExporter): | ||
| """Remove client data added by framework auto-instrumentation before export.""" | ||
|
|
||
| def __init__(self, delegate: SpanExporter) -> None: | ||
| self._delegate = delegate | ||
|
|
||
| def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: | ||
| return self._delegate.export(tuple(self._sanitize(span) for span in spans)) | ||
|
|
||
| def shutdown(self) -> None: | ||
| self._delegate.shutdown() | ||
|
|
||
| def force_flush(self, timeout_millis: int = 30000) -> bool: | ||
| return self._delegate.force_flush(timeout_millis) | ||
|
|
||
| @staticmethod | ||
| def _sanitize(span: ReadableSpan) -> ReadableSpan: | ||
| attributes = { | ||
| key: value | ||
| for key, value in span.attributes.items() | ||
| if key not in _SENSITIVE_ATTRIBUTES | ||
| and not key.startswith(_SENSITIVE_ATTRIBUTE_PREFIXES) | ||
| } | ||
| events = tuple( | ||
| Event( | ||
| event.name, | ||
| {"exception.type": event.attributes["exception.type"]} | ||
| if event.name == "exception" | ||
| and event.attributes | ||
| and "exception.type" in event.attributes | ||
| else {}, | ||
| event.timestamp, | ||
| ) | ||
| for event in span.events | ||
| ) | ||
| return ReadableSpan( | ||
| name=span.name, | ||
| context=span.context, | ||
| parent=span.parent, | ||
| resource=span.resource, | ||
| attributes=attributes, | ||
| events=events, | ||
| links=span.links, | ||
| kind=span.kind, | ||
| status=Status(span.status.status_code), | ||
| start_time=span.start_time, | ||
| end_time=span.end_time, | ||
| instrumentation_scope=span.instrumentation_scope, | ||
| ) | ||
|
|
||
|
|
||
| def _resource_attributes() -> dict[str, str]: | ||
| """Build low-cardinality resource identity from deployment metadata only.""" | ||
| attributes = { | ||
| "service.name": os.environ.get("OTEL_SERVICE_NAME", _SERVICE_NAME), | ||
| "k8s.container.name": "mcp-server", | ||
| } | ||
| optional_attributes = { | ||
| "service.version": os.environ.get("CODEALIVE_MCP_VERSION"), | ||
| "service.instance.id": os.environ.get("POD_NAME") | ||
| or os.environ.get("HOSTNAME"), | ||
| "deployment.environment.name": os.environ.get("DEPLOYMENT_ENVIRONMENT") | ||
| or os.environ.get("ENVIRONMENT"), | ||
| "k8s.namespace.name": os.environ.get("POD_NAMESPACE"), | ||
| "k8s.pod.name": os.environ.get("POD_NAME"), | ||
| "k8s.node.name": os.environ.get("NODE_NAME"), | ||
| } | ||
| attributes.update( | ||
| {key: value for key, value in optional_attributes.items() if value} | ||
| ) | ||
| return attributes | ||
|
|
||
|
|
||
| def init_tracing() -> None: | ||
| """Bootstrap OpenTelemetry tracing. | ||
|
|
||
| * If ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, traces are exported via | ||
| OTLP/HTTP (protobuf) to that endpoint. | ||
| * Otherwise a no-op provider is configured (zero overhead). | ||
| * If a generic or traces-specific OTLP endpoint is set, traces are exported | ||
| via OTLP/HTTP (protobuf). The exporter reads the standard OTel env vars so | ||
| it can apply the correct ``/v1/traces`` path semantics. | ||
| * Otherwise a provider without an exporter is configured (no network I/O). | ||
| * HTTPX client instrumentation is always enabled so that ``traceparent`` | ||
| propagates to the CodeAlive backend regardless of whether traces are | ||
| exported. | ||
| """ | ||
| otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") | ||
| otlp_endpoint = os.environ.get( | ||
| "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" | ||
| ) or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") | ||
|
Comment on lines
+128
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
|
|
||
| resource = Resource.create({"service.name": _SERVICE_NAME}) | ||
| resource = Resource.create(_resource_attributes()) | ||
|
|
||
| if otlp_endpoint: | ||
| from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter | ||
| from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( | ||
| OTLPSpanExporter, | ||
| ) | ||
| from opentelemetry.sdk.trace.export import BatchSpanProcessor | ||
|
|
||
| exporter = OTLPSpanExporter(endpoint=otlp_endpoint) | ||
| # Do not pass the endpoint explicitly. The exporter distinguishes the | ||
| # signal-specific URL from the generic base URL and appends /v1/traces | ||
| # only where the OTel environment-variable contract requires it. | ||
| exporter = OTLPSpanExporter() | ||
| provider = TracerProvider(resource=resource) | ||
| provider.add_span_processor(BatchSpanProcessor(exporter)) | ||
| provider.add_span_processor( | ||
| BatchSpanProcessor(_SanitizingSpanExporter(exporter)) | ||
| ) | ||
| trace.set_tracer_provider(provider) | ||
|
|
||
| logger.info( | ||
|
|
@@ -59,5 +162,8 @@ def init_tracing() -> None: | |
| # Flush pending spans on process exit | ||
| atexit.register(provider.shutdown) | ||
|
|
||
| # Auto-instrument httpx so outbound requests carry traceparent | ||
| # Instrument before FastMCP creates its Starlette app. Neither integration | ||
| # captures bodies by default; health endpoints are excluded through the | ||
| # standard OTEL_PYTHON_STARLETTE_EXCLUDED_URLS deployment setting. | ||
| StarletteInstrumentor().instrument() | ||
| HTTPXClientInstrumentor().instrument() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove this exception or retain
cryptography49.0.0 until the quarantine expires: this August 4 commit makes the lock select 50.0.0 even thoughuv.lockrecords its artifacts as uploaded on July 31, only about four days earlier. This directly bypasses the repository's mandatory seven-day waiting period for versioned dependencies.AGENTS.md reference: AGENTS.md:L22-L27
Useful? React with 👍 / 👎.