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.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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