codegraph-engine 3.0.0__2-py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Data models for asynchronous message queues, task dispatches, and cross-service traces.
|
|
2
|
+
|
|
3
|
+
Epistemic Invariants:
|
|
4
|
+
1. Never collapse distributed message passing into a generic synchronous CALLS edge.
|
|
5
|
+
2. Distinct semantic relationships:
|
|
6
|
+
- PRODUCES_TO_QUEUE: Producer AST call site -> Queue/Topic entity
|
|
7
|
+
- CONSUMES_FROM_QUEUE: Queue/Topic entity -> Consumer Worker AST handler
|
|
8
|
+
- STATIC_QUEUE_LINKED: Same-repository static topic match (static inference, not runtime proof)
|
|
9
|
+
- OTEL_DISTRIBUTED_VERIFIED: OpenTelemetry runtime-confirmed trace/span/link proof
|
|
10
|
+
3. Queue identity: `f"{system}:{destination}"` (system + topic/queue name).
|
|
11
|
+
4. Never persist raw message bodies or unredacted keys.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncMessagingSystem(StrEnum):
|
|
21
|
+
"""Supported message brokers and asynchronous execution systems."""
|
|
22
|
+
|
|
23
|
+
KAFKA = "kafka"
|
|
24
|
+
CELERY = "celery"
|
|
25
|
+
AWS_SQS = "aws_sqs"
|
|
26
|
+
RABBITMQ = "rabbitmq"
|
|
27
|
+
REDIS = "redis"
|
|
28
|
+
GENERIC = "generic"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncQueueEvidenceClass(StrEnum):
|
|
32
|
+
"""Epistemic evidence classification for asynchronous queue links."""
|
|
33
|
+
|
|
34
|
+
STATIC_QUEUE_LINKED = "STATIC_QUEUE_LINKED"
|
|
35
|
+
OTEL_DISTRIBUTED_VERIFIED = "OTEL_DISTRIBUTED_VERIFIED"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncOperationType(StrEnum):
|
|
39
|
+
"""Operation performed on the message broker."""
|
|
40
|
+
|
|
41
|
+
PUBLISH = "publish"
|
|
42
|
+
CONSUME = "consume"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AsyncQueueEntity:
|
|
47
|
+
"""Logical queue, topic, or task channel."""
|
|
48
|
+
|
|
49
|
+
queue_id: str # f"{system}:{destination}"
|
|
50
|
+
system: AsyncMessagingSystem
|
|
51
|
+
destination: str # topic name, queue URL, or celery task name
|
|
52
|
+
service: str = ""
|
|
53
|
+
environment: str = ""
|
|
54
|
+
is_dead_letter_queue: bool = False
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return asdict(self)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AsyncProducerRecord:
|
|
62
|
+
"""Static or runtime-observed message publisher/producer call site."""
|
|
63
|
+
|
|
64
|
+
system: AsyncMessagingSystem
|
|
65
|
+
destination: str
|
|
66
|
+
queue_id: str
|
|
67
|
+
caller_canonical_id: str
|
|
68
|
+
file_path: str
|
|
69
|
+
line: int
|
|
70
|
+
call_snippet: str
|
|
71
|
+
confidence: str = "HIGH"
|
|
72
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
return {
|
|
76
|
+
"system": str(self.system),
|
|
77
|
+
"destination": self.destination,
|
|
78
|
+
"queue_id": self.queue_id,
|
|
79
|
+
"caller_canonical_id": self.caller_canonical_id,
|
|
80
|
+
"file_path": self.file_path,
|
|
81
|
+
"line": self.line,
|
|
82
|
+
"call_snippet": self.call_snippet,
|
|
83
|
+
"confidence": self.confidence,
|
|
84
|
+
"metadata": dict(self.metadata),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class AsyncConsumerRecord:
|
|
90
|
+
"""Static or runtime-observed message subscriber/consumer worker handler."""
|
|
91
|
+
|
|
92
|
+
system: AsyncMessagingSystem
|
|
93
|
+
destination: str
|
|
94
|
+
queue_id: str
|
|
95
|
+
handler_canonical_id: str
|
|
96
|
+
file_path: str
|
|
97
|
+
line: int
|
|
98
|
+
handler_snippet: str
|
|
99
|
+
confidence: str = "HIGH"
|
|
100
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"system": str(self.system),
|
|
105
|
+
"destination": self.destination,
|
|
106
|
+
"queue_id": self.queue_id,
|
|
107
|
+
"handler_canonical_id": self.handler_canonical_id,
|
|
108
|
+
"file_path": self.file_path,
|
|
109
|
+
"line": self.line,
|
|
110
|
+
"handler_snippet": self.handler_snippet,
|
|
111
|
+
"confidence": self.confidence,
|
|
112
|
+
"metadata": dict(self.metadata),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class AsyncQueueLink:
|
|
118
|
+
"""Bridged relationship between a producer and a consumer across an async boundary."""
|
|
119
|
+
|
|
120
|
+
link_id: str
|
|
121
|
+
queue_id: str
|
|
122
|
+
system: AsyncMessagingSystem
|
|
123
|
+
destination: str
|
|
124
|
+
producer_id: str
|
|
125
|
+
producer_file: str
|
|
126
|
+
producer_line: int
|
|
127
|
+
consumer_id: str
|
|
128
|
+
consumer_file: str
|
|
129
|
+
consumer_line: int
|
|
130
|
+
evidence_class: AsyncQueueEvidenceClass
|
|
131
|
+
confidence: str = "HIGH"
|
|
132
|
+
trace_count: int = 1
|
|
133
|
+
avg_latency_ms: float = 0.0
|
|
134
|
+
p50_ms: float = 0.0
|
|
135
|
+
p95_ms: float = 0.0
|
|
136
|
+
p99_ms: float = 0.0
|
|
137
|
+
error_count: int = 0
|
|
138
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
"link_id": self.link_id,
|
|
143
|
+
"queue_id": self.queue_id,
|
|
144
|
+
"system": str(self.system),
|
|
145
|
+
"destination": self.destination,
|
|
146
|
+
"producer_id": self.producer_id,
|
|
147
|
+
"producer_file": self.producer_file,
|
|
148
|
+
"producer_line": self.producer_line,
|
|
149
|
+
"consumer_id": self.consumer_id,
|
|
150
|
+
"consumer_file": self.consumer_file,
|
|
151
|
+
"consumer_line": self.consumer_line,
|
|
152
|
+
"evidence_class": str(self.evidence_class),
|
|
153
|
+
"confidence": self.confidence,
|
|
154
|
+
"trace_count": self.trace_count,
|
|
155
|
+
"avg_latency_ms": self.avg_latency_ms,
|
|
156
|
+
"p50_ms": self.p50_ms,
|
|
157
|
+
"p95_ms": self.p95_ms,
|
|
158
|
+
"p99_ms": self.p99_ms,
|
|
159
|
+
"error_count": self.error_count,
|
|
160
|
+
"metadata": dict(self.metadata),
|
|
161
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Canonical OpenTelemetry models and reconciliation classifications.
|
|
2
|
+
|
|
3
|
+
Epistemic Invariant:
|
|
4
|
+
1. Static and runtime evidence are distinct:
|
|
5
|
+
- STATIC_QUEUE_LINKED: Same-repo static topic match.
|
|
6
|
+
- OTEL_DISTRIBUTED_VERIFIED: Real empirical telemetry links producer and consumer.
|
|
7
|
+
- RUNTIME_ONLY_OBSERVED: Observed in telemetry, but consumer or producer lives in another repository/service.
|
|
8
|
+
- STATIC_RUNTIME_CONFLICT: Static code points to queue X, but runtime points to queue Y or reports persistent failure.
|
|
9
|
+
- UNKNOWN: Inconclusive / unobserved.
|
|
10
|
+
2. Zero payload retention: Never persist message bodies, tokens, or raw sensitive headers.
|
|
11
|
+
3. Correlation IDs are stored strictly as salted SHA-256 digests.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OTelEvidenceStatus(StrEnum):
|
|
21
|
+
"""Epistemic evidence classification for static and distributed links."""
|
|
22
|
+
|
|
23
|
+
STATIC_QUEUE_LINKED = "STATIC_QUEUE_LINKED"
|
|
24
|
+
OTEL_DISTRIBUTED_VERIFIED = "OTEL_DISTRIBUTED_VERIFIED"
|
|
25
|
+
RUNTIME_ONLY_OBSERVED = "RUNTIME_ONLY_OBSERVED"
|
|
26
|
+
STATIC_RUNTIME_CONFLICT = "STATIC_RUNTIME_CONFLICT"
|
|
27
|
+
UNKNOWN = "UNKNOWN"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class OTelSpanRecord:
|
|
32
|
+
"""Sanitized, canonical representation of an ingested OpenTelemetry span."""
|
|
33
|
+
|
|
34
|
+
trace_id: str
|
|
35
|
+
span_id: str
|
|
36
|
+
parent_span_id: str = ""
|
|
37
|
+
service_name: str = ""
|
|
38
|
+
name: str = ""
|
|
39
|
+
kind: str = "INTERNAL" # PRODUCER, CONSUMER, SERVER, CLIENT, INTERNAL
|
|
40
|
+
start_time_unix_nano: int = 0
|
|
41
|
+
end_time_unix_nano: int = 0
|
|
42
|
+
duration_ms: float = 0.0
|
|
43
|
+
status_code: str = "OK" # OK, ERROR, UNSET
|
|
44
|
+
messaging_system: str = ""
|
|
45
|
+
messaging_destination: str = ""
|
|
46
|
+
messaging_operation: str = "" # publish, process, receive
|
|
47
|
+
correlation_id_hash: str = "" # Salted SHA-256 digest
|
|
48
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
links: tuple[tuple[str, str], ...] = () # ((trace_id, span_id), ...)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def composite_id(self) -> str:
|
|
53
|
+
"""Stable idempotent key for deduplication."""
|
|
54
|
+
return f"{self.trace_id}:{self.span_id}"
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
d = asdict(self)
|
|
58
|
+
d["links"] = [{"trace_id": t, "span_id": s} for t, s in self.links]
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TraceReconciliationResult:
|
|
64
|
+
"""Summary of an idempotent OTel trace ingestion and reconciliation pass."""
|
|
65
|
+
|
|
66
|
+
spans_ingested: int
|
|
67
|
+
spans_deduplicated: int
|
|
68
|
+
links_upgraded: int
|
|
69
|
+
links_created_runtime_only: int
|
|
70
|
+
conflicts_detected: int
|
|
71
|
+
details: list[dict[str, Any]] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return asdict(self)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""OpenTelemetry span parser, adapter layer, and privacy sanitizer.
|
|
2
|
+
|
|
3
|
+
Guarantees:
|
|
4
|
+
1. Adapters for both standard OTLP JSON (resourceSpans) and flat span lists.
|
|
5
|
+
2. Privacy: Strips all message bodies, payloads, bearer tokens, cookies, and secrets.
|
|
6
|
+
3. Hashing: Salted SHA-256 for correlation/message IDs without saving raw identifiers.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from codegraph.async_queue.otel_models import OTelSpanRecord
|
|
15
|
+
|
|
16
|
+
_SENSITIVE_KEY_SUBSTRINGS = (
|
|
17
|
+
"auth",
|
|
18
|
+
"token",
|
|
19
|
+
"cookie",
|
|
20
|
+
"secret",
|
|
21
|
+
"password",
|
|
22
|
+
"credential",
|
|
23
|
+
"payload",
|
|
24
|
+
"body",
|
|
25
|
+
"message_body",
|
|
26
|
+
"raw_message",
|
|
27
|
+
"data",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_correlation_id(raw_id: str | int) -> str:
|
|
32
|
+
"""Generate a deterministic, salted SHA-256 hash for correlation identifiers."""
|
|
33
|
+
clean = str(raw_id).strip()
|
|
34
|
+
if not clean:
|
|
35
|
+
return ""
|
|
36
|
+
digest = hashlib.sha256(f"cg_corr_salt_v1:{clean}".encode()).hexdigest()
|
|
37
|
+
return digest[:16]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sanitize_attributes(raw_attrs: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
"""Sanitize attributes dictionary, removing any payload bodies, headers, or secrets."""
|
|
42
|
+
sanitized: dict[str, Any] = {}
|
|
43
|
+
for k, v in raw_attrs.items():
|
|
44
|
+
k_lower = k.lower()
|
|
45
|
+
if any(bad in k_lower for bad in _SENSITIVE_KEY_SUBSTRINGS):
|
|
46
|
+
continue
|
|
47
|
+
# Preserve primitive values only
|
|
48
|
+
if isinstance(v, (int, float, bool)):
|
|
49
|
+
sanitized[k] = v
|
|
50
|
+
elif isinstance(v, str):
|
|
51
|
+
if len(v) > 256:
|
|
52
|
+
v = v[:256] + "...[truncated]"
|
|
53
|
+
sanitized[k] = v
|
|
54
|
+
elif isinstance(v, (list, tuple)):
|
|
55
|
+
if len(v) <= 10 and all(isinstance(x, (str, int, float, bool)) for x in v):
|
|
56
|
+
sanitized[k] = list(v)
|
|
57
|
+
return sanitized
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_otlp_value(val_obj: Any) -> Any:
|
|
61
|
+
"""Unpack OTLP AnyValue object (e.g. {'stringValue': 'kafka'})."""
|
|
62
|
+
if not isinstance(val_obj, dict):
|
|
63
|
+
return val_obj
|
|
64
|
+
if "stringValue" in val_obj:
|
|
65
|
+
return val_obj["stringValue"]
|
|
66
|
+
if "intValue" in val_obj:
|
|
67
|
+
try:
|
|
68
|
+
return int(val_obj["intValue"])
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
return 0
|
|
71
|
+
if "doubleValue" in val_obj:
|
|
72
|
+
try:
|
|
73
|
+
return float(val_obj["doubleValue"])
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
return 0.0
|
|
76
|
+
if "boolValue" in val_obj:
|
|
77
|
+
return bool(val_obj["boolValue"])
|
|
78
|
+
if "arrayValue" in val_obj and "values" in val_obj["arrayValue"]:
|
|
79
|
+
return [_extract_otlp_value(x) for x in val_obj["arrayValue"]["values"]]
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unpack_otlp_key_values(kv_list: list[dict[str, Any]]) -> dict[str, Any]:
|
|
84
|
+
"""Convert an OTLP KeyValue list [{'key': 'k', 'value': {...}}] to a dict."""
|
|
85
|
+
out: dict[str, Any] = {}
|
|
86
|
+
for item in kv_list:
|
|
87
|
+
if isinstance(item, dict) and "key" in item:
|
|
88
|
+
out[str(item["key"])] = _extract_otlp_value(item.get("value"))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OTLPJsonAdapter:
|
|
93
|
+
"""Adapter for standard OpenTelemetry OTLP JSON (resourceSpans)."""
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def can_handle(cls, data: dict[str, Any]) -> bool:
|
|
97
|
+
return "resourceSpans" in data
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def parse(cls, data: dict[str, Any]) -> list[OTelSpanRecord]:
|
|
101
|
+
records: list[OTelSpanRecord] = []
|
|
102
|
+
resource_spans = data.get("resourceSpans", [])
|
|
103
|
+
if not isinstance(resource_spans, list):
|
|
104
|
+
return records
|
|
105
|
+
|
|
106
|
+
for rs in resource_spans:
|
|
107
|
+
service_name = ""
|
|
108
|
+
if isinstance(rs, dict):
|
|
109
|
+
res = rs.get("resource", {})
|
|
110
|
+
if isinstance(res, dict):
|
|
111
|
+
res_attrs = res.get("attributes", [])
|
|
112
|
+
if isinstance(res_attrs, list):
|
|
113
|
+
attrs_map = _unpack_otlp_key_values(res_attrs)
|
|
114
|
+
service_name = str(attrs_map.get("service.name", ""))
|
|
115
|
+
|
|
116
|
+
scope_spans = rs.get("scopeSpans", [])
|
|
117
|
+
if isinstance(scope_spans, list):
|
|
118
|
+
for ss in scope_spans:
|
|
119
|
+
if isinstance(ss, dict):
|
|
120
|
+
spans = ss.get("spans", [])
|
|
121
|
+
if isinstance(spans, list):
|
|
122
|
+
for s in spans:
|
|
123
|
+
rec = cls._parse_span(s, service_name)
|
|
124
|
+
if rec:
|
|
125
|
+
records.append(rec)
|
|
126
|
+
return records
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def _parse_span(cls, s: dict[str, Any], default_service: str) -> OTelSpanRecord | None:
|
|
130
|
+
trace_id = str(s.get("traceId", "")).strip()
|
|
131
|
+
span_id = str(s.get("spanId", "")).strip()
|
|
132
|
+
if not trace_id or not span_id:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
parent_span_id = str(s.get("parentSpanId", "")).strip()
|
|
136
|
+
name = str(s.get("name", "")).strip()
|
|
137
|
+
kind_raw = s.get("kind", 0)
|
|
138
|
+
|
|
139
|
+
# Map OTLP SpanKind int or str
|
|
140
|
+
kind_map = {
|
|
141
|
+
1: "INTERNAL",
|
|
142
|
+
2: "SERVER",
|
|
143
|
+
3: "CLIENT",
|
|
144
|
+
4: "PRODUCER",
|
|
145
|
+
5: "CONSUMER",
|
|
146
|
+
"SPAN_KIND_INTERNAL": "INTERNAL",
|
|
147
|
+
"SPAN_KIND_SERVER": "SERVER",
|
|
148
|
+
"SPAN_KIND_CLIENT": "CLIENT",
|
|
149
|
+
"SPAN_KIND_PRODUCER": "PRODUCER",
|
|
150
|
+
"SPAN_KIND_CONSUMER": "CONSUMER",
|
|
151
|
+
}
|
|
152
|
+
kind = kind_map.get(kind_raw, str(kind_raw).replace("SPAN_KIND_", ""))
|
|
153
|
+
|
|
154
|
+
start_nano = int(s.get("startTimeUnixNano", 0) or 0)
|
|
155
|
+
end_nano = int(s.get("endTimeUnixNano", 0) or 0)
|
|
156
|
+
duration_ms = (end_nano - start_nano) / 1_000_000.0 if end_nano > start_nano else 0.0
|
|
157
|
+
|
|
158
|
+
# Status
|
|
159
|
+
status_code = "OK"
|
|
160
|
+
status_dict = s.get("status", {})
|
|
161
|
+
if isinstance(status_dict, dict):
|
|
162
|
+
raw_code = str(status_dict.get("code", "")).upper()
|
|
163
|
+
if "ERROR" in raw_code or raw_code == "2":
|
|
164
|
+
status_code = "ERROR"
|
|
165
|
+
|
|
166
|
+
# Attributes
|
|
167
|
+
raw_attrs: dict[str, Any] = {}
|
|
168
|
+
if isinstance(s.get("attributes"), list):
|
|
169
|
+
raw_attrs = _unpack_otlp_key_values(s["attributes"])
|
|
170
|
+
elif isinstance(s.get("attributes"), dict):
|
|
171
|
+
raw_attrs = dict(s["attributes"])
|
|
172
|
+
|
|
173
|
+
service_name = str(raw_attrs.get("service.name") or default_service)
|
|
174
|
+
|
|
175
|
+
# Semantic conventions for messaging
|
|
176
|
+
msg_system = str(raw_attrs.get("messaging.system", "")).lower()
|
|
177
|
+
msg_dest = str(raw_attrs.get("messaging.destination", ""))
|
|
178
|
+
msg_op = str(raw_attrs.get("messaging.operation", "")).lower()
|
|
179
|
+
|
|
180
|
+
# If operation wasn't explicitly set, infer from span kind
|
|
181
|
+
if not msg_op:
|
|
182
|
+
if kind == "PRODUCER":
|
|
183
|
+
msg_op = "publish"
|
|
184
|
+
elif kind == "CONSUMER":
|
|
185
|
+
msg_op = "process"
|
|
186
|
+
|
|
187
|
+
# Correlation ID hashing
|
|
188
|
+
corr_id = raw_attrs.get("messaging.message_id") or raw_attrs.get("messaging.conversation_id") or raw_attrs.get("correlation_id") or ""
|
|
189
|
+
corr_hash = hash_correlation_id(corr_id) if corr_id else ""
|
|
190
|
+
|
|
191
|
+
# Span links
|
|
192
|
+
links_list: list[tuple[str, str]] = []
|
|
193
|
+
raw_links = s.get("links", [])
|
|
194
|
+
if isinstance(raw_links, list):
|
|
195
|
+
for lk in raw_links:
|
|
196
|
+
if isinstance(lk, dict):
|
|
197
|
+
l_trace = str(lk.get("traceId", "")).strip()
|
|
198
|
+
l_span = str(lk.get("spanId", "")).strip()
|
|
199
|
+
if l_trace and l_span:
|
|
200
|
+
links_list.append((l_trace, l_span))
|
|
201
|
+
|
|
202
|
+
sanitized = sanitize_attributes(raw_attrs)
|
|
203
|
+
|
|
204
|
+
return OTelSpanRecord(
|
|
205
|
+
trace_id=trace_id,
|
|
206
|
+
span_id=span_id,
|
|
207
|
+
parent_span_id=parent_span_id,
|
|
208
|
+
service_name=service_name,
|
|
209
|
+
name=name,
|
|
210
|
+
kind=kind,
|
|
211
|
+
start_time_unix_nano=start_nano,
|
|
212
|
+
end_time_unix_nano=end_nano,
|
|
213
|
+
duration_ms=duration_ms,
|
|
214
|
+
status_code=status_code,
|
|
215
|
+
messaging_system=msg_system,
|
|
216
|
+
messaging_destination=msg_dest,
|
|
217
|
+
messaging_operation=msg_op,
|
|
218
|
+
correlation_id_hash=corr_hash,
|
|
219
|
+
attributes=sanitized,
|
|
220
|
+
links=tuple(links_list),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class GenericSpanListAdapter:
|
|
225
|
+
"""Adapter for flat list of spans (Jaeger, custom JSON, or SDK exports)."""
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def can_handle(cls, data: Any) -> bool:
|
|
229
|
+
if isinstance(data, list):
|
|
230
|
+
return True
|
|
231
|
+
if isinstance(data, dict) and "spans" in data:
|
|
232
|
+
return True
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def parse(cls, data: Any) -> list[OTelSpanRecord]:
|
|
237
|
+
span_items = data if isinstance(data, list) else data.get("spans", [])
|
|
238
|
+
records: list[OTelSpanRecord] = []
|
|
239
|
+
if not isinstance(span_items, list):
|
|
240
|
+
return records
|
|
241
|
+
|
|
242
|
+
for s in span_items:
|
|
243
|
+
if not isinstance(s, dict):
|
|
244
|
+
continue
|
|
245
|
+
trace_id = str(s.get("trace_id") or s.get("traceId") or "").strip()
|
|
246
|
+
span_id = str(s.get("span_id") or s.get("spanId") or "").strip()
|
|
247
|
+
if not trace_id or not span_id:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
parent_span_id = str(s.get("parent_span_id") or s.get("parentSpanId") or "").strip()
|
|
251
|
+
name = str(s.get("name") or s.get("operation_name") or "").strip()
|
|
252
|
+
kind = str(s.get("kind") or "INTERNAL").upper()
|
|
253
|
+
service_name = str(s.get("service_name") or s.get("service") or "")
|
|
254
|
+
|
|
255
|
+
duration_ms = float(s.get("duration_ms") or 0.0)
|
|
256
|
+
if not duration_ms and "duration_us" in s:
|
|
257
|
+
duration_ms = float(s["duration_us"]) / 1000.0
|
|
258
|
+
|
|
259
|
+
status_code = str(s.get("status") or s.get("status_code") or "OK").upper()
|
|
260
|
+
if "ERR" in status_code:
|
|
261
|
+
status_code = "ERROR"
|
|
262
|
+
|
|
263
|
+
raw_attrs = dict(s.get("attributes") or s.get("tags") or {})
|
|
264
|
+
if not service_name and "service.name" in raw_attrs:
|
|
265
|
+
service_name = str(raw_attrs["service.name"])
|
|
266
|
+
|
|
267
|
+
msg_system = str(raw_attrs.get("messaging.system", "")).lower()
|
|
268
|
+
msg_dest = str(raw_attrs.get("messaging.destination", ""))
|
|
269
|
+
msg_op = str(raw_attrs.get("messaging.operation", "")).lower()
|
|
270
|
+
|
|
271
|
+
if not msg_op:
|
|
272
|
+
if "PROD" in kind:
|
|
273
|
+
msg_op = "publish"
|
|
274
|
+
elif "CONS" in kind:
|
|
275
|
+
msg_op = "process"
|
|
276
|
+
|
|
277
|
+
corr_id = raw_attrs.get("messaging.message_id") or raw_attrs.get("correlation_id") or ""
|
|
278
|
+
corr_hash = hash_correlation_id(corr_id) if corr_id else ""
|
|
279
|
+
|
|
280
|
+
links_list: list[tuple[str, str]] = []
|
|
281
|
+
for lk in s.get("links", []):
|
|
282
|
+
if isinstance(lk, dict):
|
|
283
|
+
l_trace = str(lk.get("trace_id") or lk.get("traceId") or "").strip()
|
|
284
|
+
l_span = str(lk.get("span_id") or lk.get("spanId") or "").strip()
|
|
285
|
+
if l_trace and l_span:
|
|
286
|
+
links_list.append((l_trace, l_span))
|
|
287
|
+
|
|
288
|
+
sanitized = sanitize_attributes(raw_attrs)
|
|
289
|
+
|
|
290
|
+
records.append(
|
|
291
|
+
OTelSpanRecord(
|
|
292
|
+
trace_id=trace_id,
|
|
293
|
+
span_id=span_id,
|
|
294
|
+
parent_span_id=parent_span_id,
|
|
295
|
+
service_name=service_name,
|
|
296
|
+
name=name,
|
|
297
|
+
kind=kind,
|
|
298
|
+
start_time_unix_nano=int(s.get("start_time_unix_nano", 0) or 0),
|
|
299
|
+
end_time_unix_nano=int(s.get("end_time_unix_nano", 0) or 0),
|
|
300
|
+
duration_ms=duration_ms,
|
|
301
|
+
status_code=status_code,
|
|
302
|
+
messaging_system=msg_system,
|
|
303
|
+
messaging_destination=msg_dest,
|
|
304
|
+
messaging_operation=msg_op,
|
|
305
|
+
correlation_id_hash=corr_hash,
|
|
306
|
+
attributes=sanitized,
|
|
307
|
+
links=tuple(links_list),
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
return records
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_otel_spans(data_or_text: Any) -> list[OTelSpanRecord]:
|
|
314
|
+
"""Parse raw OpenTelemetry JSON string or dictionary/list into canonical records."""
|
|
315
|
+
if isinstance(data_or_text, str):
|
|
316
|
+
try:
|
|
317
|
+
data = json.loads(data_or_text)
|
|
318
|
+
except Exception:
|
|
319
|
+
return []
|
|
320
|
+
else:
|
|
321
|
+
data = data_or_text
|
|
322
|
+
|
|
323
|
+
if isinstance(data, dict) and OTLPJsonAdapter.can_handle(data):
|
|
324
|
+
raw_records = OTLPJsonAdapter.parse(data)
|
|
325
|
+
elif GenericSpanListAdapter.can_handle(data):
|
|
326
|
+
raw_records = GenericSpanListAdapter.parse(data)
|
|
327
|
+
else:
|
|
328
|
+
raw_records = []
|
|
329
|
+
|
|
330
|
+
# Deduplicate within batch by composite_id
|
|
331
|
+
seen: set[str] = set()
|
|
332
|
+
deduped: list[OTelSpanRecord] = []
|
|
333
|
+
for r in raw_records:
|
|
334
|
+
if r.composite_id not in seen:
|
|
335
|
+
seen.add(r.composite_id)
|
|
336
|
+
deduped.append(r)
|
|
337
|
+
return deduped
|