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.
Files changed (82) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +564 -6
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/branch_comparison.py +197 -0
  20. codegraph/change_impact.py +547 -0
  21. codegraph/cli.py +992 -3
  22. codegraph/cli_output.py +17 -2
  23. codegraph/context.py +38 -11
  24. codegraph/context_freshness.py +351 -0
  25. codegraph/database/__init__.py +14 -0
  26. codegraph/database/extractor.py +712 -17
  27. codegraph/database/interrogation.py +388 -2
  28. codegraph/database/schema_drift.py +862 -0
  29. codegraph/epistemic.py +7 -0
  30. codegraph/evidence_contract.py +21 -0
  31. codegraph/frameworks.py +171 -6
  32. codegraph/frameworks_nestjs.py +502 -0
  33. codegraph/frameworks_nextjs.py +337 -0
  34. codegraph/frameworks_react.py +457 -0
  35. codegraph/freshness.py +2 -0
  36. codegraph/frontend_html.py +109 -0
  37. codegraph/git.py +2 -0
  38. codegraph/git_state.py +545 -0
  39. codegraph/indexing/__init__.py +7 -1
  40. codegraph/indexing/indexer.py +291 -38
  41. codegraph/indexing/models.py +6 -0
  42. codegraph/indexing/parser.py +239 -0
  43. codegraph/indexing/pool.py +136 -0
  44. codegraph/indexing/scanner.py +6 -0
  45. codegraph/indexing/templates.py +418 -0
  46. codegraph/interrogation.py +128 -20
  47. codegraph/mcp/server.py +258 -27
  48. codegraph/models.py +7 -0
  49. codegraph/monorepo.py +3 -0
  50. codegraph/optimizer.py +42 -5
  51. codegraph/process_lifecycle.py +31 -13
  52. codegraph/refactor/__init__.py +36 -0
  53. codegraph/refactor/models.py +114 -0
  54. codegraph/refactor/renamer.py +844 -0
  55. codegraph/refactor/transactions.py +403 -0
  56. codegraph/resolver.py +1202 -159
  57. codegraph/retrieval_policy.py +6 -0
  58. codegraph/route_topology.py +172 -0
  59. codegraph/runtime/__init__.py +19 -0
  60. codegraph/runtime/collector.py +155 -0
  61. codegraph/runtime/db_watcher.py +191 -0
  62. codegraph/runtime/ingestor.py +30 -10
  63. codegraph/runtime/port_inspector.py +304 -0
  64. codegraph/runtime/runner.py +155 -6
  65. codegraph/runtime/trace_linker.py +175 -0
  66. codegraph/search/hybrid.py +26 -0
  67. codegraph/semantic_conflicts.py +270 -0
  68. codegraph/semantic_decorators.py +34 -3
  69. codegraph/structural_diff.py +542 -0
  70. codegraph/styles.py +144 -0
  71. codegraph/symbol_history.py +277 -0
  72. codegraph/tsconfig.py +236 -0
  73. codegraph/ui/__init__.py +6 -0
  74. codegraph/ui/server.py +715 -0
  75. codegraph/watcher.py +323 -0
  76. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +257 -39
  77. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  78. codegraph_engine-2.2.1.dist-info/RECORD +0 -87
  79. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  80. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  81. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  82. {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
+ )
@@ -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
- fs.appendFileSync(logPath, JSON.stringify(event) + '\\n');
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 sys
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
- with open(LOG_PATH, "a", encoding="utf-8") as f:
102
- f.write(json.dumps(event) + "\\n")
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
- # Intercept uncaught exceptions
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
+ )