c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""OpenTelemetry-native tracing for Cognee.
|
|
|
|
Provides CogneeSpanExporter (in-memory span buffer), setup_tracing(),
|
|
get_tracer(), CogneeTrace, semantic attribute constants, and redaction.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import threading
|
|
from collections import defaultdict
|
|
from typing import Optional, Sequence
|
|
|
|
try:
|
|
from opentelemetry import trace
|
|
from opentelemetry.sdk.trace import TracerProvider, ReadableSpan
|
|
from opentelemetry.sdk.trace.export import (
|
|
SpanExporter,
|
|
SpanExportResult,
|
|
SimpleSpanProcessor,
|
|
ConsoleSpanExporter,
|
|
)
|
|
from opentelemetry.sdk.resources import Resource
|
|
from opentelemetry.trace import StatusCode
|
|
|
|
_OTEL_AVAILABLE = True
|
|
except ImportError:
|
|
_OTEL_AVAILABLE = False
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Semantic attribute constants
|
|
# ---------------------------------------------------------------------------
|
|
COGNEE_DB_SYSTEM = "cognee.db.system"
|
|
COGNEE_DB_QUERY = "cognee.db.query"
|
|
COGNEE_DB_ROW_COUNT = "cognee.db.row_count"
|
|
COGNEE_LLM_MODEL = "cognee.llm.model"
|
|
COGNEE_LLM_PROVIDER = "cognee.llm.provider"
|
|
COGNEE_PIPELINE_STAGE = "cognee.pipeline.stage"
|
|
COGNEE_SEARCH_TYPE = "cognee.search.type"
|
|
COGNEE_SEARCH_QUERY = "cognee.search.query"
|
|
COGNEE_PIPELINE_TASK_NAME = "cognee.pipeline.task_name"
|
|
COGNEE_VECTOR_COLLECTION = "cognee.vector.collection"
|
|
COGNEE_VECTOR_RESULT_COUNT = "cognee.vector.result_count"
|
|
COGNEE_SPAN_CATEGORY = "cognee.span.category"
|
|
COGNEE_RESULT_SUMMARY = "cognee.result.summary"
|
|
COGNEE_RESULT_COUNT = "cognee.result.count"
|
|
COGNEE_PIPELINE_NAME = "cognee.pipeline.name"
|
|
|
|
# V2 API attributes
|
|
COGNEE_DATASET_NAME = "cognee.dataset.name"
|
|
COGNEE_SESSION_ID = "cognee.session.id"
|
|
COGNEE_SESSION_ENTRY_COUNT = "cognee.session.entry_count"
|
|
COGNEE_DATA_SIZE_BYTES = "cognee.data.size_bytes"
|
|
COGNEE_DATA_ITEM_COUNT = "cognee.data.item_count"
|
|
COGNEE_OPERATION_MODE = "cognee.operation.mode" # "session", "permanent", "cloud"
|
|
COGNEE_RECALL_SCOPE = "cognee.recall.scope" # "session", "graph", "auto"
|
|
COGNEE_RECALL_SOURCE = "cognee.recall.source" # where results came from
|
|
COGNEE_FORGET_TARGET = "cognee.forget.target" # "dataset", "data_item", "everything"
|
|
COGNEE_IMPROVE_STAGES = "cognee.improve.stages"
|
|
COGNEE_GRAPH_EDGES_SYNCED = "cognee.graph.edges_synced" # compatibility; no longer emitted
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secret redaction
|
|
# ---------------------------------------------------------------------------
|
|
_SECRET_PATTERNS = [
|
|
re.compile(r"(sk-[A-Za-z0-9]{20,})"),
|
|
re.compile(r"(api[_-]?key\s*[=:]\s*)['\"]?[A-Za-z0-9\-_]{16,}['\"]?", re.IGNORECASE),
|
|
re.compile(r"(bearer\s+)[A-Za-z0-9\-_\.]{20,}", re.IGNORECASE),
|
|
re.compile(r"(password\s*[=:]\s*)['\"]?[^\s'\"]{8,}['\"]?", re.IGNORECASE),
|
|
]
|
|
|
|
|
|
def redact_secrets(text: str) -> str:
|
|
"""Redact common API key and secret patterns from text."""
|
|
if not text:
|
|
return text
|
|
result = text
|
|
for pattern in _SECRET_PATTERNS:
|
|
result = pattern.sub(lambda m: m.group(0)[:6] + "***REDACTED***", result)
|
|
return result
|
|
|
|
|
|
def _check_otel_available() -> None:
|
|
if not _OTEL_AVAILABLE:
|
|
raise ImportError(
|
|
"OpenTelemetry packages are required for tracing. "
|
|
"Install them with: pip install cognee[tracing]"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory span exporter
|
|
# ---------------------------------------------------------------------------
|
|
_MAX_TRACES = 50
|
|
|
|
if _OTEL_AVAILABLE:
|
|
_ExporterBase = SpanExporter
|
|
else:
|
|
_ExporterBase = object
|
|
|
|
|
|
class CogneeSpanExporter(_ExporterBase):
|
|
"""Custom SpanExporter that buffers completed spans in-memory.
|
|
|
|
Spans are grouped by trace_id. The buffer is bounded to the last
|
|
``_MAX_TRACES`` distinct traces.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._traces: dict[str, list[dict]] = defaultdict(list)
|
|
self._trace_order: list[str] = []
|
|
|
|
def export(self, spans: Sequence) -> "SpanExportResult":
|
|
with self._lock:
|
|
for span in spans:
|
|
trace_id = format(span.context.trace_id, "032x")
|
|
span_dict = {
|
|
"name": span.name,
|
|
"trace_id": trace_id,
|
|
"span_id": format(span.context.span_id, "016x"),
|
|
"parent_span_id": (
|
|
format(span.parent.span_id, "016x") if span.parent else None
|
|
),
|
|
"start_time_ns": span.start_time,
|
|
"end_time_ns": span.end_time,
|
|
"duration_ms": (
|
|
(span.end_time - span.start_time) / 1_000_000
|
|
if span.end_time and span.start_time
|
|
else 0.0
|
|
),
|
|
"status": span.status.status_code.name if span.status else "UNSET",
|
|
"attributes": dict(span.attributes) if span.attributes else {},
|
|
}
|
|
self._traces[trace_id].append(span_dict)
|
|
if trace_id not in self._trace_order:
|
|
self._trace_order.append(trace_id)
|
|
|
|
# Evict oldest traces if over limit
|
|
while len(self._trace_order) > _MAX_TRACES:
|
|
oldest = self._trace_order.pop(0)
|
|
self._traces.pop(oldest, None)
|
|
|
|
return SpanExportResult.SUCCESS if _OTEL_AVAILABLE else None
|
|
|
|
def shutdown(self) -> None:
|
|
pass
|
|
|
|
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
return True
|
|
|
|
# -- Public helpers for reading collected traces --
|
|
|
|
def get_last_trace_spans(self) -> Optional[list[dict]]:
|
|
with self._lock:
|
|
if not self._trace_order:
|
|
return None
|
|
last_id = self._trace_order[-1]
|
|
return list(self._traces[last_id])
|
|
|
|
def get_all_traces(self) -> dict[str, list[dict]]:
|
|
with self._lock:
|
|
return {tid: list(spans) for tid, spans in self._traces.items()}
|
|
|
|
def clear(self) -> None:
|
|
with self._lock:
|
|
self._traces.clear()
|
|
self._trace_order.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CogneeTrace -- lightweight view over collected spans
|
|
# ---------------------------------------------------------------------------
|
|
class CogneeTrace:
|
|
"""Lightweight read-only view over spans belonging to a single trace."""
|
|
|
|
def __init__(self, span_list: list[dict]) -> None:
|
|
self._spans = span_list
|
|
|
|
def spans(self) -> list[dict]:
|
|
"""Return flat list of span dicts sorted by start time."""
|
|
return sorted(self._spans, key=lambda s: s.get("start_time_ns", 0))
|
|
|
|
def summary(self) -> dict:
|
|
"""Return summary with operation, total_duration_ms, breakdown, errors."""
|
|
if not self._spans:
|
|
return {
|
|
"operation": "",
|
|
"total_duration_ms": 0.0,
|
|
"span_count": 0,
|
|
"breakdown": {},
|
|
"errors": [],
|
|
}
|
|
|
|
sorted_spans = self.spans()
|
|
|
|
# Find root span (no parent)
|
|
root = next((s for s in sorted_spans if s.get("parent_span_id") is None), sorted_spans[0])
|
|
|
|
# Build breakdown by span name prefix (group by base name)
|
|
breakdown: dict[str, dict] = defaultdict(lambda: {"count": 0, "total_ms": 0.0})
|
|
errors: list[str] = []
|
|
|
|
for s in sorted_spans:
|
|
name = s["name"]
|
|
breakdown[name]["count"] += 1
|
|
breakdown[name]["total_ms"] += s.get("duration_ms", 0.0)
|
|
if s.get("status") == "ERROR":
|
|
errors.append(f"{name}: error")
|
|
|
|
return {
|
|
"operation": root["name"],
|
|
"total_duration_ms": root.get("duration_ms", 0.0),
|
|
"span_count": len(sorted_spans),
|
|
"breakdown": dict(breakdown),
|
|
"errors": errors,
|
|
}
|
|
|
|
def tree(self) -> dict:
|
|
"""Return hierarchical span tree as nested dicts."""
|
|
spans_by_id = {s["span_id"]: {**s, "children": []} for s in self._spans}
|
|
roots = []
|
|
|
|
for s in self._spans:
|
|
node = spans_by_id[s["span_id"]]
|
|
parent_id = s.get("parent_span_id")
|
|
if parent_id and parent_id in spans_by_id:
|
|
spans_by_id[parent_id]["children"].append(node)
|
|
else:
|
|
roots.append(node)
|
|
|
|
return roots[0] if len(roots) == 1 else {"children": roots}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Global state
|
|
# ---------------------------------------------------------------------------
|
|
_exporter: Optional["CogneeSpanExporter"] = None
|
|
_tracer: Optional["trace.Tracer"] = None
|
|
_provider: Optional["TracerProvider"] = None
|
|
|
|
|
|
def _is_auto_instrumented() -> bool:
|
|
"""Return True if an external tool (e.g. opentelemetry-instrument) already
|
|
configured a real TracerProvider."""
|
|
if not _OTEL_AVAILABLE:
|
|
return False
|
|
current = trace.get_tracer_provider()
|
|
# The default ProxyTracerProvider is set before any real provider is configured.
|
|
# If the current provider is something else, auto-instrumentation is active.
|
|
return current is not None and type(current).__name__ != "ProxyTracerProvider"
|
|
|
|
|
|
def _try_add_otlp_exporter(provider: "TracerProvider") -> None:
|
|
"""If an OTLP endpoint is configured, add an OTLP span exporter.
|
|
|
|
Reads the endpoint from ``BaseConfig.otel_exporter_otlp_endpoint``.
|
|
The OTLP exporters also honour the standard ``OTEL_EXPORTER_OTLP_*``
|
|
env vars for headers, compression, etc.
|
|
"""
|
|
from cognee.base_config import get_base_config
|
|
|
|
config = get_base_config()
|
|
if not config.otel_exporter_otlp_endpoint:
|
|
return
|
|
|
|
try:
|
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
OTLPSpanExporter,
|
|
)
|
|
|
|
otlp_exporter = OTLPSpanExporter(endpoint=config.otel_exporter_otlp_endpoint)
|
|
provider.add_span_processor(SimpleSpanProcessor(otlp_exporter))
|
|
except ImportError:
|
|
try:
|
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
OTLPSpanExporter as OTLPHttpSpanExporter,
|
|
)
|
|
|
|
otlp_exporter = OTLPHttpSpanExporter(endpoint=config.otel_exporter_otlp_endpoint)
|
|
provider.add_span_processor(SimpleSpanProcessor(otlp_exporter))
|
|
except ImportError:
|
|
import warnings
|
|
|
|
warnings.warn(
|
|
"otel_exporter_otlp_endpoint is set but no OTLP exporter is installed. "
|
|
"Install with: pip install cognee[tracing]",
|
|
stacklevel=2,
|
|
)
|
|
|
|
|
|
def setup_tracing(console_output: bool = False) -> "trace.Tracer":
|
|
"""Set up OTEL tracing for Cognee.
|
|
|
|
If an external auto-instrumentation tool (e.g. ``opentelemetry-instrument``,
|
|
Dash0, Datadog) has already configured a TracerProvider, this function
|
|
attaches Cognee's in-memory exporter to it instead of replacing it.
|
|
|
|
When no external provider exists, creates a new TracerProvider and
|
|
optionally adds an OTLP exporter (if ``OTEL_EXPORTER_OTLP_ENDPOINT`` is
|
|
set) and a ConsoleSpanExporter for debugging.
|
|
|
|
Returns the cognee tracer.
|
|
"""
|
|
_check_otel_available()
|
|
|
|
from cognee.version import get_cognee_version
|
|
|
|
global _exporter, _tracer, _provider
|
|
|
|
version = get_cognee_version()
|
|
_exporter = CogneeSpanExporter()
|
|
|
|
if _is_auto_instrumented():
|
|
# An external tool already set up a provider — reuse it.
|
|
_provider = trace.get_tracer_provider()
|
|
# Attach our in-memory exporter so CogneeTrace still works.
|
|
# The underlying provider must support add_span_processor;
|
|
# the auto-instrumented TracerProvider (from opentelemetry-sdk) does.
|
|
if hasattr(_provider, "add_span_processor"):
|
|
_provider.add_span_processor(SimpleSpanProcessor(_exporter))
|
|
else:
|
|
# No external provider — create our own.
|
|
from cognee.base_config import get_base_config
|
|
|
|
config = get_base_config()
|
|
resource = Resource.create(
|
|
{
|
|
"service.name": config.otel_service_name,
|
|
"service.version": version,
|
|
"deployment.environment": os.getenv("ENV", "development"),
|
|
}
|
|
)
|
|
|
|
_provider = TracerProvider(resource=resource)
|
|
_provider.add_span_processor(SimpleSpanProcessor(_exporter))
|
|
|
|
# Add OTLP exporter when endpoint is configured (e.g. Dash0, Grafana, etc.)
|
|
_try_add_otlp_exporter(_provider)
|
|
|
|
if console_output:
|
|
_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
|
|
|
|
trace.set_tracer_provider(_provider)
|
|
|
|
_tracer = _provider.get_tracer("cognee", version)
|
|
return _tracer
|
|
|
|
|
|
def get_tracer() -> Optional["trace.Tracer"]:
|
|
"""Return the cognee OTEL tracer, or None if not set up."""
|
|
return _tracer
|
|
|
|
|
|
def get_provider() -> Optional["TracerProvider"]:
|
|
"""Return the TracerProvider (useful for adding external exporters)."""
|
|
return _provider
|
|
|
|
|
|
def get_exporter() -> Optional["CogneeSpanExporter"]:
|
|
"""Return the in-memory exporter."""
|
|
return _exporter
|
|
|
|
|
|
def shutdown_tracing(timeout_ms: int = 30000) -> None:
|
|
"""Shut down the TracerProvider and clear global state."""
|
|
global _exporter, _tracer, _provider
|
|
|
|
if _provider is not None:
|
|
_provider.force_flush(timeout_millis=timeout_ms)
|
|
_provider.shutdown()
|
|
_exporter = None
|
|
_tracer = None
|
|
_provider = None
|