codegraph-engine 2.2.1__py3-none-any.whl → 3.0.0__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 +1 -1
- codegraph/agent_brain.py +172 -2
- codegraph/agent_capabilities.py +564 -6
- codegraph/agent_rules.py +1 -1
- codegraph/api_drift.py +455 -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/branch_comparison.py +197 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +992 -3
- codegraph/cli_output.py +17 -2
- codegraph/context.py +38 -11
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +7 -0
- codegraph/evidence_contract.py +21 -0
- codegraph/frameworks.py +171 -6
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +545 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/indexer.py +291 -38
- codegraph/indexing/models.py +6 -0
- codegraph/indexing/parser.py +239 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +6 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +128 -20
- codegraph/mcp/server.py +258 -27
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/optimizer.py +42 -5
- codegraph/process_lifecycle.py +31 -13
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +403 -0
- codegraph/resolver.py +1202 -159
- codegraph/retrieval_policy.py +6 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +19 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +715 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +257 -39
- codegraph_engine-3.0.0.dist-info/RECORD +129 -0
- codegraph_engine-2.2.1.dist-info/RECORD +0 -87
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Localhost Port and Process Inspector for Multi-Service Architectures.
|
|
2
|
+
|
|
3
|
+
Deterministically detects, fingerprints, and attributes running localhost services:
|
|
4
|
+
1. Port <-> PID mapping via lsof / netstat / /proc.
|
|
5
|
+
2. Process CWD and command-line attribution to workspace packages.
|
|
6
|
+
3. Static port inference from docker-compose.yml, package.json, and .env.
|
|
7
|
+
4. Framework fingerprinting (Next.js, Vite, FastAPI, NestJS, Express, PostgreSQL, MongoDB).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ServiceEndpoint:
|
|
22
|
+
port: int
|
|
23
|
+
host: str = "localhost"
|
|
24
|
+
pid: int | None = None
|
|
25
|
+
service_name: str = "unknown"
|
|
26
|
+
framework: str = "unknown"
|
|
27
|
+
process_cwd: str = ""
|
|
28
|
+
relative_repo_path: str = ""
|
|
29
|
+
command: str = ""
|
|
30
|
+
is_live: bool = False
|
|
31
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"port": self.port,
|
|
36
|
+
"host": self.host,
|
|
37
|
+
"pid": self.pid,
|
|
38
|
+
"service_name": self.service_name,
|
|
39
|
+
"framework": self.framework,
|
|
40
|
+
"process_cwd": self.process_cwd,
|
|
41
|
+
"relative_repo_path": self.relative_repo_path,
|
|
42
|
+
"command": self.command,
|
|
43
|
+
"is_live": self.is_live,
|
|
44
|
+
"attributes": dict(self.attributes),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _infer_framework_from_cmd(cmd: str) -> str:
|
|
49
|
+
cmd_lower = cmd.lower()
|
|
50
|
+
if "next" in cmd_lower:
|
|
51
|
+
return "Next.js"
|
|
52
|
+
if "vite" in cmd_lower:
|
|
53
|
+
return "Vite"
|
|
54
|
+
if "uvicorn" in cmd_lower or "fastapi" in cmd_lower:
|
|
55
|
+
return "FastAPI"
|
|
56
|
+
if "nest" in cmd_lower:
|
|
57
|
+
return "NestJS"
|
|
58
|
+
if "express" in cmd_lower or "node" in cmd_lower:
|
|
59
|
+
return "Node/Express"
|
|
60
|
+
if "postgres" in cmd_lower:
|
|
61
|
+
return "PostgreSQL"
|
|
62
|
+
if "mongod" in cmd_lower or "mongo" in cmd_lower:
|
|
63
|
+
return "MongoDB"
|
|
64
|
+
if "redis" in cmd_lower:
|
|
65
|
+
return "Redis"
|
|
66
|
+
if "celery" in cmd_lower:
|
|
67
|
+
return "Celery"
|
|
68
|
+
return "GenericService"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _infer_service_name_from_path(repo_root: Path, cwd_path: Path, framework: str) -> str:
|
|
72
|
+
try:
|
|
73
|
+
rel = cwd_path.relative_to(repo_root)
|
|
74
|
+
parts = rel.parts
|
|
75
|
+
if parts:
|
|
76
|
+
if parts[0] in ("apps", "packages", "services") and len(parts) > 1:
|
|
77
|
+
return parts[1]
|
|
78
|
+
return parts[-1]
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
return framework.lower().replace(".", "").replace("/", "-")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_static_configured_ports(repo_root: Path) -> dict[int, ServiceEndpoint]:
|
|
85
|
+
"""Scan docker-compose, package.json, and .env files with in-memory TTL caching."""
|
|
86
|
+
global _STATIC_PORT_CACHE
|
|
87
|
+
root_key = str(repo_root)
|
|
88
|
+
now = time.monotonic()
|
|
89
|
+
if root_key in _STATIC_PORT_CACHE:
|
|
90
|
+
last_t, cached_map = _STATIC_PORT_CACHE[root_key]
|
|
91
|
+
if now - last_t < _PORT_CACHE_TTL_SEC:
|
|
92
|
+
return cached_map
|
|
93
|
+
|
|
94
|
+
results: dict[int, ServiceEndpoint] = {}
|
|
95
|
+
|
|
96
|
+
# 1. docker-compose.yml / compose.yaml
|
|
97
|
+
for compose_name in ("docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml"):
|
|
98
|
+
compose_file = repo_root / compose_name
|
|
99
|
+
if compose_file.exists():
|
|
100
|
+
try:
|
|
101
|
+
content = compose_file.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
# match ports: - "3000:3000" or - 8000:8000
|
|
103
|
+
for m in re.finditer(r"""['"]?(\d{2,5}):(\d{2,5})['"]?""", content):
|
|
104
|
+
host_port = int(m.group(1))
|
|
105
|
+
results[host_port] = ServiceEndpoint(
|
|
106
|
+
port=host_port,
|
|
107
|
+
service_name=f"docker-service-{host_port}",
|
|
108
|
+
framework="DockerContainer",
|
|
109
|
+
relative_repo_path=str(compose_file.relative_to(repo_root)),
|
|
110
|
+
is_live=False,
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# 2. package.json scripts (e.g. -p 3000, PORT=4000)
|
|
116
|
+
for pkg_file in repo_root.glob("**/package.json"):
|
|
117
|
+
if "node_modules" in pkg_file.parts:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
content = pkg_file.read_text(encoding="utf-8", errors="replace")
|
|
121
|
+
for m in re.finditer(r"""(?:PORT\s*=\s*|-p\s+|--port\s+)(\d{2,5})""", content):
|
|
122
|
+
port_num = int(m.group(1))
|
|
123
|
+
rel_dir = pkg_file.parent.relative_to(repo_root)
|
|
124
|
+
s_name = pkg_file.parent.name or "web"
|
|
125
|
+
results[port_num] = ServiceEndpoint(
|
|
126
|
+
port=port_num,
|
|
127
|
+
service_name=s_name,
|
|
128
|
+
framework="Node/Web",
|
|
129
|
+
relative_repo_path=str(rel_dir),
|
|
130
|
+
is_live=False,
|
|
131
|
+
)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# 3. .env files
|
|
136
|
+
for env_file in (repo_root / ".env", repo_root / ".env.development", repo_root / ".env.local"):
|
|
137
|
+
if env_file.exists():
|
|
138
|
+
try:
|
|
139
|
+
content = env_file.read_text(encoding="utf-8", errors="replace")
|
|
140
|
+
for m in re.finditer(r"""^\s*(?:PORT|SERVER_PORT|API_PORT)\s*=\s*(\d{2,5})""", content, re.MULTILINE):
|
|
141
|
+
port_num = int(m.group(1))
|
|
142
|
+
results[port_num] = ServiceEndpoint(
|
|
143
|
+
port=port_num,
|
|
144
|
+
service_name="env-service",
|
|
145
|
+
framework="ConfiguredService",
|
|
146
|
+
relative_repo_path=str(env_file.relative_to(repo_root)),
|
|
147
|
+
is_live=False,
|
|
148
|
+
)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
_STATIC_PORT_CACHE[root_key] = (now, results)
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_PORT_CACHE: tuple[float, list[ServiceEndpoint]] = (0.0, [])
|
|
157
|
+
_STATIC_PORT_CACHE: dict[str, tuple[float, dict[int, ServiceEndpoint]]] = {}
|
|
158
|
+
_PORT_CACHE_TTL_SEC = 5.0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def discover_live_listening_ports(repo_root: Path | None = None) -> list[ServiceEndpoint]:
|
|
162
|
+
"""Inspect active OS listening ports on localhost with 5-second TTL cache (Zero-CPU idle)."""
|
|
163
|
+
global _PORT_CACHE
|
|
164
|
+
now = time.monotonic()
|
|
165
|
+
last_time, cached_endpoints = _PORT_CACHE
|
|
166
|
+
if now - last_time < _PORT_CACHE_TTL_SEC and cached_endpoints:
|
|
167
|
+
return cached_endpoints
|
|
168
|
+
|
|
169
|
+
endpoints: list[ServiceEndpoint] = []
|
|
170
|
+
seen_ports: set[int] = set()
|
|
171
|
+
|
|
172
|
+
lsof_bin = shutil.which("lsof")
|
|
173
|
+
if lsof_bin:
|
|
174
|
+
try:
|
|
175
|
+
# lsof -iTCP -sTCP:LISTEN -n -P
|
|
176
|
+
proc = subprocess.run(
|
|
177
|
+
[lsof_bin, "-iTCP", "-sTCP:LISTEN", "-n", "-P"],
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
timeout=3,
|
|
181
|
+
check=False,
|
|
182
|
+
)
|
|
183
|
+
if proc.returncode == 0:
|
|
184
|
+
for line in proc.stdout.splitlines()[1:]:
|
|
185
|
+
parts = line.split()
|
|
186
|
+
if len(parts) >= 9:
|
|
187
|
+
cmd = parts[0]
|
|
188
|
+
pid_str = parts[1]
|
|
189
|
+
addr_str = parts[8]
|
|
190
|
+
# address format: *:3000, 127.0.0.1:8000, [::1]:5432
|
|
191
|
+
m = re.search(r":(\d+)$", addr_str)
|
|
192
|
+
if m:
|
|
193
|
+
port = int(m.group(1))
|
|
194
|
+
if port in seen_ports:
|
|
195
|
+
continue
|
|
196
|
+
seen_ports.add(port)
|
|
197
|
+
pid = int(pid_str) if pid_str.isdigit() else None
|
|
198
|
+
cwd_str = ""
|
|
199
|
+
rel_repo = ""
|
|
200
|
+
if pid:
|
|
201
|
+
cwd_str = _get_process_cwd(pid)
|
|
202
|
+
if repo_root and cwd_str:
|
|
203
|
+
try:
|
|
204
|
+
rel_repo = str(Path(cwd_str).relative_to(repo_root))
|
|
205
|
+
except Exception:
|
|
206
|
+
rel_repo = ""
|
|
207
|
+
framework = _infer_framework_from_cmd(cmd)
|
|
208
|
+
svc_name = (
|
|
209
|
+
_infer_service_name_from_path(repo_root, Path(cwd_str), framework)
|
|
210
|
+
if repo_root and cwd_str
|
|
211
|
+
else cmd
|
|
212
|
+
)
|
|
213
|
+
endpoints.append(
|
|
214
|
+
ServiceEndpoint(
|
|
215
|
+
port=port,
|
|
216
|
+
pid=pid,
|
|
217
|
+
service_name=svc_name,
|
|
218
|
+
framework=framework,
|
|
219
|
+
process_cwd=cwd_str,
|
|
220
|
+
relative_repo_path=rel_repo,
|
|
221
|
+
command=cmd,
|
|
222
|
+
is_live=True,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
_PORT_CACHE = (now, endpoints)
|
|
229
|
+
return endpoints
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _get_process_cwd(pid: int) -> str:
|
|
233
|
+
"""Get process working directory securely across macOS and Linux."""
|
|
234
|
+
# 1. Linux /proc/<pid>/cwd
|
|
235
|
+
proc_cwd = Path(f"/proc/{pid}/cwd")
|
|
236
|
+
if proc_cwd.exists():
|
|
237
|
+
try:
|
|
238
|
+
return str(proc_cwd.resolve())
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# 2. macOS lsof -p <pid> | grep cwd
|
|
243
|
+
lsof_bin = shutil.which("lsof")
|
|
244
|
+
if lsof_bin:
|
|
245
|
+
try:
|
|
246
|
+
proc = subprocess.run(
|
|
247
|
+
[lsof_bin, "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
timeout=1,
|
|
251
|
+
check=False,
|
|
252
|
+
)
|
|
253
|
+
for line in proc.stdout.splitlines():
|
|
254
|
+
if line.startswith("n"):
|
|
255
|
+
return line[1:].strip()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolve_port_to_service(
|
|
262
|
+
port: int,
|
|
263
|
+
repo_root: Path,
|
|
264
|
+
live_endpoints: list[ServiceEndpoint] | None = None,
|
|
265
|
+
) -> ServiceEndpoint:
|
|
266
|
+
"""Identify the exact service, package, and framework for a given localhost port."""
|
|
267
|
+
if live_endpoints is None:
|
|
268
|
+
live_endpoints = discover_live_listening_ports(repo_root)
|
|
269
|
+
|
|
270
|
+
for ep in live_endpoints:
|
|
271
|
+
if ep.port == port:
|
|
272
|
+
return ep
|
|
273
|
+
|
|
274
|
+
static_map = discover_static_configured_ports(repo_root)
|
|
275
|
+
if port in static_map:
|
|
276
|
+
return static_map[port]
|
|
277
|
+
|
|
278
|
+
# Common standard ports fallback
|
|
279
|
+
standard_defaults: dict[int, tuple[str, str]] = {
|
|
280
|
+
3000: ("frontend", "Next.js/React"),
|
|
281
|
+
5173: ("frontend", "Vite"),
|
|
282
|
+
8000: ("backend-api", "FastAPI/Django"),
|
|
283
|
+
4000: ("backend-api", "NestJS/Express"),
|
|
284
|
+
5000: ("backend-api", "Flask/Express"),
|
|
285
|
+
5432: ("database", "PostgreSQL"),
|
|
286
|
+
27017: ("database", "MongoDB"),
|
|
287
|
+
6379: ("cache", "Redis"),
|
|
288
|
+
3306: ("database", "MySQL"),
|
|
289
|
+
}
|
|
290
|
+
if port in standard_defaults:
|
|
291
|
+
s_name, fw = standard_defaults[port]
|
|
292
|
+
return ServiceEndpoint(
|
|
293
|
+
port=port,
|
|
294
|
+
service_name=s_name,
|
|
295
|
+
framework=fw,
|
|
296
|
+
is_live=False,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return ServiceEndpoint(
|
|
300
|
+
port=port,
|
|
301
|
+
service_name=f"service-{port}",
|
|
302
|
+
framework="Unknown",
|
|
303
|
+
is_live=False,
|
|
304
|
+
)
|
codegraph/runtime/runner.py
CHANGED
|
@@ -35,13 +35,35 @@ const fs = require('fs');
|
|
|
35
35
|
|
|
36
36
|
const logPath = {escaped_log_path};
|
|
37
37
|
|
|
38
|
+
let _cg_buffer = [];
|
|
39
|
+
let _cg_last_flush = Date.now();
|
|
40
|
+
const _CG_BATCH_SIZE = 50;
|
|
41
|
+
const _CG_FLUSH_INTERVAL_MS = 1500;
|
|
42
|
+
|
|
43
|
+
function _cg_flush_buffer() {{
|
|
44
|
+
if (_cg_buffer.length === 0) return;
|
|
45
|
+
const content = _cg_buffer.join('');
|
|
46
|
+
_cg_buffer = [];
|
|
47
|
+
_cg_last_flush = Date.now();
|
|
48
|
+
try {{
|
|
49
|
+
fs.appendFileSync(logPath, content);
|
|
50
|
+
}} catch (_) {{}}
|
|
51
|
+
}}
|
|
52
|
+
|
|
38
53
|
function emitEvent(event) {{
|
|
39
54
|
try {{
|
|
40
|
-
|
|
55
|
+
_cg_buffer.push(JSON.stringify(event) + '\\n');
|
|
56
|
+
if (_cg_buffer.length >= _CG_BATCH_SIZE || (Date.now() - _cg_last_flush) >= _CG_FLUSH_INTERVAL_MS) {{
|
|
57
|
+
_cg_flush_buffer();
|
|
58
|
+
}}
|
|
41
59
|
}} catch (_) {{}}
|
|
42
60
|
}}
|
|
43
61
|
|
|
62
|
+
setInterval(_cg_flush_buffer, _CG_FLUSH_INTERVAL_MS);
|
|
63
|
+
process.on('exit', _cg_flush_buffer);
|
|
64
|
+
|
|
44
65
|
function hookServer(serverProto) {{
|
|
66
|
+
|
|
45
67
|
const origEmit = serverProto.emit;
|
|
46
68
|
serverProto.emit = function(event, req, res) {{
|
|
47
69
|
if (event === 'request' && req && res) {{
|
|
@@ -87,23 +109,64 @@ process.on('uncaughtExceptionMonitor', (err) => {{
|
|
|
87
109
|
|
|
88
110
|
|
|
89
111
|
def _generate_python_interceptor(output_jsonl: Path) -> str:
|
|
90
|
-
"""Generate lightweight Python WSGI/ASGI/FastAPI request interceptor."""
|
|
112
|
+
"""Generate lightweight Python WSGI/ASGI/FastAPI request interceptor with in-memory buffering."""
|
|
91
113
|
escaped_log_path = repr(str(output_jsonl))
|
|
92
114
|
return f"""# CodeGraph Python Runtime Interceptor
|
|
93
|
-
import
|
|
115
|
+
import atexit
|
|
94
116
|
import json
|
|
117
|
+
import os
|
|
118
|
+
import sys
|
|
119
|
+
import threading
|
|
95
120
|
import time
|
|
96
121
|
|
|
97
122
|
LOG_PATH = {escaped_log_path}
|
|
98
123
|
|
|
124
|
+
_cg_buffer = []
|
|
125
|
+
_cg_lock = threading.Lock()
|
|
126
|
+
_cg_last_flush = time.time()
|
|
127
|
+
_cg_batch_size = 50
|
|
128
|
+
_cg_flush_interval = 1.5
|
|
129
|
+
|
|
130
|
+
def _cg_flush():
|
|
131
|
+
global _cg_last_flush
|
|
132
|
+
lines = None
|
|
133
|
+
with _cg_lock:
|
|
134
|
+
if _cg_buffer:
|
|
135
|
+
lines = "".join(_cg_buffer)
|
|
136
|
+
_cg_buffer.clear()
|
|
137
|
+
_cg_last_flush = time.time()
|
|
138
|
+
if lines:
|
|
139
|
+
try:
|
|
140
|
+
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
|
141
|
+
f.write(lines)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
99
145
|
def emit_event(event):
|
|
100
146
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
147
|
+
line = json.dumps(event) + "\\n"
|
|
148
|
+
should_flush = False
|
|
149
|
+
with _cg_lock:
|
|
150
|
+
_cg_buffer.append(line)
|
|
151
|
+
if len(_cg_buffer) >= _cg_batch_size or (time.time() - _cg_last_flush) >= _cg_flush_interval:
|
|
152
|
+
should_flush = True
|
|
153
|
+
if should_flush:
|
|
154
|
+
_cg_flush()
|
|
103
155
|
except Exception:
|
|
104
156
|
pass
|
|
105
157
|
|
|
106
|
-
|
|
158
|
+
def _cg_bg_flusher():
|
|
159
|
+
while True:
|
|
160
|
+
time.sleep(_cg_flush_interval)
|
|
161
|
+
if _cg_buffer:
|
|
162
|
+
_cg_flush()
|
|
163
|
+
|
|
164
|
+
_cg_flusher_thread = threading.Thread(target=_cg_bg_flusher, daemon=True)
|
|
165
|
+
_cg_flusher_thread.start()
|
|
166
|
+
atexit.register(_cg_flush)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# 1. Intercept uncaught exceptions
|
|
107
170
|
_orig_excepthook = sys.excepthook
|
|
108
171
|
def _cg_excepthook(exc_type, exc_value, exc_traceback):
|
|
109
172
|
emit_event({{
|
|
@@ -118,6 +181,92 @@ def _cg_excepthook(exc_type, exc_value, exc_traceback):
|
|
|
118
181
|
return _orig_excepthook(exc_type, exc_value, exc_traceback)
|
|
119
182
|
|
|
120
183
|
sys.excepthook = _cg_excepthook
|
|
184
|
+
|
|
185
|
+
# 2. Intercept SQLite query executions
|
|
186
|
+
try:
|
|
187
|
+
import sqlite3
|
|
188
|
+
_orig_connect = sqlite3.connect
|
|
189
|
+
|
|
190
|
+
def _cg_connect(*args, **kwargs):
|
|
191
|
+
con = _orig_connect(*args, **kwargs)
|
|
192
|
+
def _sql_trace(statement):
|
|
193
|
+
if statement and isinstance(statement, str) and statement.strip():
|
|
194
|
+
emit_event({{
|
|
195
|
+
"trace_id": "tr_sql_" + str(int(time.time())),
|
|
196
|
+
"span_id": "sp_sql_" + str(id(con)),
|
|
197
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
198
|
+
"sql": statement,
|
|
199
|
+
"duration_ms": 1.0,
|
|
200
|
+
"service_name": "python_dev_server",
|
|
201
|
+
"source_format": "otel",
|
|
202
|
+
}})
|
|
203
|
+
try:
|
|
204
|
+
con.set_trace_callback(_sql_trace)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
return con
|
|
208
|
+
|
|
209
|
+
sqlite3.connect = _cg_connect
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# 3. Intercept SQLAlchemy engine query executions if imported
|
|
215
|
+
try:
|
|
216
|
+
import sqlalchemy.event
|
|
217
|
+
import sqlalchemy.engine
|
|
218
|
+
|
|
219
|
+
@sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "after_cursor_execute")
|
|
220
|
+
def _cg_after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
|
221
|
+
if statement and str(statement).strip():
|
|
222
|
+
emit_event({{
|
|
223
|
+
"trace_id": "tr_sql_" + str(int(time.time())),
|
|
224
|
+
"span_id": "sp_sql_" + str(id(cursor)),
|
|
225
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
226
|
+
"sql": str(statement),
|
|
227
|
+
"duration_ms": 1.0,
|
|
228
|
+
"service_name": "python_dev_server",
|
|
229
|
+
"source_format": "otel",
|
|
230
|
+
}})
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
# 4. Intercept Werkzeug/Flask dev server requests
|
|
235
|
+
try:
|
|
236
|
+
import werkzeug.serving
|
|
237
|
+
_orig_run_simple = werkzeug.serving.run_simple
|
|
238
|
+
|
|
239
|
+
def _cg_run_simple(hostname, port, application, *args, **kwargs):
|
|
240
|
+
def _wrapped_app(environ, start_response):
|
|
241
|
+
path = environ.get("PATH_INFO", "/")
|
|
242
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
243
|
+
status_box = [200]
|
|
244
|
+
def _wrapped_sr(status, headers, exc_info=None):
|
|
245
|
+
try:
|
|
246
|
+
status_box[0] = int(status.split()[0])
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
return start_response(status, headers, exc_info)
|
|
250
|
+
st = time.time()
|
|
251
|
+
try:
|
|
252
|
+
return application(environ, _wrapped_sr)
|
|
253
|
+
finally:
|
|
254
|
+
emit_event({{
|
|
255
|
+
"trace_id": "tr_http_" + str(int(time.time())),
|
|
256
|
+
"span_id": "sp_http_" + str(int(time.time())),
|
|
257
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
258
|
+
"http_method": method,
|
|
259
|
+
"route": path,
|
|
260
|
+
"status_code": status_box[0],
|
|
261
|
+
"duration_ms": (time.time() - st) * 1000.0,
|
|
262
|
+
"service_name": "python_dev_server",
|
|
263
|
+
"source_format": "otel",
|
|
264
|
+
}})
|
|
265
|
+
return _orig_run_simple(hostname, port, _wrapped_app, *args, **kwargs)
|
|
266
|
+
|
|
267
|
+
werkzeug.serving.run_simple = _cg_run_simple
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
121
270
|
"""
|
|
122
271
|
|
|
123
272
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Cross-Service & Multi-Localhost Distributed Trace Linker.
|
|
2
|
+
|
|
3
|
+
Stitches W3C distributed traces across multiple localhost ports:
|
|
4
|
+
- Frontend (e.g. Next.js on localhost:3000)
|
|
5
|
+
- Backend API (e.g. FastAPI / NestJS on localhost:8000)
|
|
6
|
+
- Database mutation (e.g. PostgreSQL on localhost:5432)
|
|
7
|
+
|
|
8
|
+
Attributes every step to:
|
|
9
|
+
1. Participating localhost service & port.
|
|
10
|
+
2. Static AST symbol / route handler.
|
|
11
|
+
3. Actual latency and HTTP status code.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sqlite3
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TraceSpanNode:
|
|
23
|
+
span_id: str
|
|
24
|
+
parent_span_id: str
|
|
25
|
+
service_name: str
|
|
26
|
+
port: int | None
|
|
27
|
+
route_path: str
|
|
28
|
+
handler_symbol: str
|
|
29
|
+
db_operation: str
|
|
30
|
+
db_table: str
|
|
31
|
+
status_code: int | None
|
|
32
|
+
duration_ms: float
|
|
33
|
+
children: tuple[TraceSpanNode, ...] = ()
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"span_id": self.span_id,
|
|
38
|
+
"parent_span_id": self.parent_span_id,
|
|
39
|
+
"service_name": self.service_name,
|
|
40
|
+
"port": self.port,
|
|
41
|
+
"route_path": self.route_path,
|
|
42
|
+
"handler_symbol": self.handler_symbol,
|
|
43
|
+
"db_operation": self.db_operation,
|
|
44
|
+
"db_table": self.db_table,
|
|
45
|
+
"status_code": self.status_code,
|
|
46
|
+
"duration_ms": self.duration_ms,
|
|
47
|
+
"children": [c.as_dict() for c in self.children],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DistributedTraceReport:
|
|
53
|
+
trace_id: str
|
|
54
|
+
total_duration_ms: float
|
|
55
|
+
services_involved: tuple[dict[str, Any], ...]
|
|
56
|
+
root_spans: tuple[TraceSpanNode, ...]
|
|
57
|
+
db_queries_count: int
|
|
58
|
+
has_errors: bool
|
|
59
|
+
summary: str
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"trace_id": self.trace_id,
|
|
64
|
+
"total_duration_ms": self.total_duration_ms,
|
|
65
|
+
"services_involved": list(self.services_involved),
|
|
66
|
+
"root_spans": [s.as_dict() for s in self.root_spans],
|
|
67
|
+
"db_queries_count": self.db_queries_count,
|
|
68
|
+
"has_errors": self.has_errors,
|
|
69
|
+
"summary": self.summary,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def link_distributed_trace(
|
|
74
|
+
con: sqlite3.Connection,
|
|
75
|
+
repo_root: Path,
|
|
76
|
+
trace_id: str,
|
|
77
|
+
) -> DistributedTraceReport | None:
|
|
78
|
+
"""Reconstruct multi-service distributed execution graph for a given trace_id."""
|
|
79
|
+
clean_tid = trace_id.strip()
|
|
80
|
+
if not clean_tid:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
rows = con.execute(
|
|
84
|
+
"""SELECT event_id, trace_id, span_id, parent_span_id, http_method, route_path,
|
|
85
|
+
handler_symbol, service_name, db_operation, db_table, status_code,
|
|
86
|
+
duration_ms
|
|
87
|
+
FROM runtime_observations
|
|
88
|
+
WHERE trace_id = ?
|
|
89
|
+
ORDER BY duration_ms DESC""",
|
|
90
|
+
(clean_tid,),
|
|
91
|
+
).fetchall()
|
|
92
|
+
|
|
93
|
+
if not rows:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Group spans
|
|
97
|
+
spans_by_id: dict[str, dict[str, Any]] = {}
|
|
98
|
+
children_by_parent: dict[str, list[str]] = {}
|
|
99
|
+
services_set: set[str] = set()
|
|
100
|
+
db_queries = 0
|
|
101
|
+
has_err = False
|
|
102
|
+
total_time = 0.0
|
|
103
|
+
|
|
104
|
+
for r in rows:
|
|
105
|
+
sid = str(r["span_id"] or r["event_id"])
|
|
106
|
+
pid = str(r["parent_span_id"] or "")
|
|
107
|
+
s_name = str(r["service_name"] or "unknown_service")
|
|
108
|
+
services_set.add(s_name)
|
|
109
|
+
dur = float(r["duration_ms"] or 0.0)
|
|
110
|
+
total_time = max(total_time, dur)
|
|
111
|
+
sc = r["status_code"]
|
|
112
|
+
if sc and int(sc) >= 400:
|
|
113
|
+
has_err = True
|
|
114
|
+
if r["db_operation"]:
|
|
115
|
+
db_queries += 1
|
|
116
|
+
|
|
117
|
+
spans_by_id[sid] = {
|
|
118
|
+
"span_id": sid,
|
|
119
|
+
"parent_span_id": pid,
|
|
120
|
+
"service_name": s_name,
|
|
121
|
+
"port": None,
|
|
122
|
+
"route_path": str(r["route_path"] or ""),
|
|
123
|
+
"handler_symbol": str(r["handler_symbol"] or ""),
|
|
124
|
+
"db_operation": str(r["db_operation"] or ""),
|
|
125
|
+
"db_table": str(r["db_table"] or ""),
|
|
126
|
+
"status_code": sc,
|
|
127
|
+
"duration_ms": dur,
|
|
128
|
+
}
|
|
129
|
+
if pid not in children_by_parent:
|
|
130
|
+
children_by_parent[pid] = []
|
|
131
|
+
children_by_parent[pid].append(sid)
|
|
132
|
+
|
|
133
|
+
# Build tree
|
|
134
|
+
def _build_node(sid: str) -> TraceSpanNode:
|
|
135
|
+
data = spans_by_id[sid]
|
|
136
|
+
child_sids = children_by_parent.get(sid, [])
|
|
137
|
+
children = tuple(_build_node(cid) for cid in child_sids if cid in spans_by_id)
|
|
138
|
+
return TraceSpanNode(
|
|
139
|
+
span_id=data["span_id"],
|
|
140
|
+
parent_span_id=data["parent_span_id"],
|
|
141
|
+
service_name=data["service_name"],
|
|
142
|
+
port=data["port"],
|
|
143
|
+
route_path=data["route_path"],
|
|
144
|
+
handler_symbol=data["handler_symbol"],
|
|
145
|
+
db_operation=data["db_operation"],
|
|
146
|
+
db_table=data["db_table"],
|
|
147
|
+
status_code=data["status_code"],
|
|
148
|
+
duration_ms=data["duration_ms"],
|
|
149
|
+
children=children,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Root spans: parent_span_id is empty or not in spans_by_id
|
|
153
|
+
roots: list[TraceSpanNode] = []
|
|
154
|
+
for sid, data in spans_by_id.items():
|
|
155
|
+
pid = data["parent_span_id"]
|
|
156
|
+
if not pid or pid not in spans_by_id:
|
|
157
|
+
roots.append(_build_node(sid))
|
|
158
|
+
|
|
159
|
+
# Resolve services
|
|
160
|
+
services_list: list[dict[str, Any]] = [{"name": s} for s in sorted(services_set)]
|
|
161
|
+
|
|
162
|
+
summary = (
|
|
163
|
+
f"Trace {clean_tid[:8]}: {len(rows)} spans across {len(services_set)} service(s), "
|
|
164
|
+
f"{db_queries} DB queries, total duration {total_time:.1f}ms."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return DistributedTraceReport(
|
|
168
|
+
trace_id=clean_tid,
|
|
169
|
+
total_duration_ms=total_time,
|
|
170
|
+
services_involved=tuple(services_list),
|
|
171
|
+
root_spans=tuple(roots),
|
|
172
|
+
db_queries_count=db_queries,
|
|
173
|
+
has_errors=has_err,
|
|
174
|
+
summary=summary,
|
|
175
|
+
)
|