codetac 0.1.0 → 0.2.0
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.
- package/package.json +10 -5
- package/readme.md +81 -14
- package/src/ai.mjs +4 -2
- package/src/boundaries.mjs +12 -9
- package/src/browser/bar.js +5 -3
- package/src/cli.mjs +251 -33
- package/src/detect-python.mjs +286 -0
- package/src/detect.mjs +21 -6
- package/src/diagnose.mjs +35 -5
- package/src/digest.mjs +18 -4
- package/src/page.mjs +19 -7
- package/src/panel.mjs +56 -14
- package/src/python/codetac_py/__init__.py +88 -0
- package/src/python/codetac_py/boundaries.py +435 -0
- package/src/python/codetac_py/capture.py +351 -0
- package/src/python/codetac_py/context.py +50 -0
- package/src/python/codetac_py/detail.py +333 -0
- package/src/python/codetac_py/files.py +135 -0
- package/src/python/codetac_py/frameworks.py +72 -0
- package/src/python/codetac_py/hooks.py +72 -0
- package/src/python/codetac_py/jinja_map.py +132 -0
- package/src/python/codetac_py/network.py +704 -0
- package/src/python/codetac_py/page.py +513 -0
- package/src/python/codetac_py/project.py +60 -0
- package/src/python/codetac_py/redact.py +104 -0
- package/src/python/codetac_py/servers.py +514 -0
- package/src/python/codetac_py/sitecustomize.py +62 -0
- package/src/python/codetac_py/writer.py +237 -0
- package/src/python/probe.py +99 -0
- package/src/recording.mjs +22 -4
- package/src/runtime.mjs +13 -1
- package/src/sentences.mjs +4 -0
- package/src/store.mjs +61 -9
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Batched JSONL writer, with the same format and policy as src/runtime.mjs.
|
|
2
|
+
|
|
3
|
+
Events are queued and written every 25 ms by a daemon thread. Adding an
|
|
4
|
+
event is a single deque append, and the enter/exit events of the capture are
|
|
5
|
+
queued as tuples of values and turned into JSON lines in the batch (stage 5):
|
|
6
|
+
formatting was most of the cost per call, and in a web app the batch is
|
|
7
|
+
written while the app waits. The order inside a file is the order of
|
|
8
|
+
`sequence`. The queue is flushed at exit and on SIGINT/SIGTERM/SIGHUP; a
|
|
9
|
+
SIGKILL or os._exit loses the last 25 ms. At exit the thread is stopped and
|
|
10
|
+
joined before the last flush: a daemon thread that wakes while the
|
|
11
|
+
interpreter shuts down crashed CPython 3.12 (SIGSEGV in take_gil, stage 8;
|
|
12
|
+
the M71 failure of stage 7).
|
|
13
|
+
|
|
14
|
+
CodeTAC's own batches are written with os.open/os.write/os.close, so they
|
|
15
|
+
never appear as file writes of the app.
|
|
16
|
+
|
|
17
|
+
Keep it importable on old Pythons (3.8+): the minimal mode uses it too.
|
|
18
|
+
"""
|
|
19
|
+
import atexit
|
|
20
|
+
import collections
|
|
21
|
+
import itertools
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import signal
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
from .redact import create_redactor
|
|
30
|
+
|
|
31
|
+
FLUSH_SECONDS = 0.025
|
|
32
|
+
INSTALL = os.path.realpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..'))
|
|
33
|
+
RUN_NAME = r'^[a-zA-Z0-9_-]{1,80}$'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def data_directory(env=None):
|
|
37
|
+
"""Same order as src/home.mjs: CODETAC_HOME, the Git checkout, ~/.codetac."""
|
|
38
|
+
env = os.environ if env is None else env
|
|
39
|
+
if env.get('CODETAC_HOME'):
|
|
40
|
+
return os.path.abspath(env['CODETAC_HOME'])
|
|
41
|
+
if os.path.exists(os.path.join(INSTALL, '.git')):
|
|
42
|
+
return os.path.join(INSTALL, '.codetac')
|
|
43
|
+
return os.path.join(os.path.expanduser('~'), '.codetac')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Writer(object):
|
|
47
|
+
def __init__(self, directory, flush_seconds=FLUSH_SECONDS):
|
|
48
|
+
self.directory = directory # created with the first batch
|
|
49
|
+
self.flush_seconds = flush_seconds
|
|
50
|
+
self.redact = create_redactor()
|
|
51
|
+
self.disabled = False
|
|
52
|
+
self.start_event = None
|
|
53
|
+
self.header = None
|
|
54
|
+
# One queue for the life of the process: the capture keeps its append.
|
|
55
|
+
self.queue = collections.deque()
|
|
56
|
+
# Reentrant: a signal handler may flush while the main thread is flushing.
|
|
57
|
+
self.lock = threading.RLock()
|
|
58
|
+
self.thread = None
|
|
59
|
+
self.stopping = threading.Event()
|
|
60
|
+
# Called every 400 ms by the writer's thread (detalhe.json, detail.py).
|
|
61
|
+
self.periodic = []
|
|
62
|
+
self._name_process()
|
|
63
|
+
# Registered first, so it runs after the app's own atexit handlers.
|
|
64
|
+
atexit.register(self.close)
|
|
65
|
+
if hasattr(os, 'register_at_fork'):
|
|
66
|
+
os.register_at_fork(after_in_child=self._after_fork)
|
|
67
|
+
|
|
68
|
+
def _name_process(self):
|
|
69
|
+
"""A new file per process, with its own sequence."""
|
|
70
|
+
pid = os.getpid()
|
|
71
|
+
self.process = '%d:0' % pid
|
|
72
|
+
self.file = os.path.join(self.directory, '%d-0-%s.jsonl' % (pid, os.urandom(16).hex()))
|
|
73
|
+
self.prefix = '{"version":1,"process":"%s","sequence":' % self.process
|
|
74
|
+
# Node ids are "<process>:<n>"; kept as the opening of a JSON string.
|
|
75
|
+
self.id_prefix = '"%s:' % self.process
|
|
76
|
+
self.sequence = itertools.count(1)
|
|
77
|
+
|
|
78
|
+
def _after_fork(self):
|
|
79
|
+
"""The child drops the parent's batch and starts its own file, with its own capture-start."""
|
|
80
|
+
self.queue.clear()
|
|
81
|
+
self.lock = threading.RLock()
|
|
82
|
+
self.thread = None
|
|
83
|
+
self.stopping = threading.Event()
|
|
84
|
+
self._name_process()
|
|
85
|
+
if self.start_event is not None:
|
|
86
|
+
self.header = self.line(self.redact_fragment(self.start_event))
|
|
87
|
+
self._start_thread()
|
|
88
|
+
|
|
89
|
+
def line(self, body):
|
|
90
|
+
"""`body` is a JSON object fragment without braces, already redacted."""
|
|
91
|
+
return '%s%d,"timeNs":%d,%s}\n' % (self.prefix, next(self.sequence), time.perf_counter_ns(), body)
|
|
92
|
+
|
|
93
|
+
def start(self, event):
|
|
94
|
+
"""capture-start: the first line of the file, written with the first real event, so
|
|
95
|
+
that a process that never runs anything of the project (a supervisor, a helper
|
|
96
|
+
started by multiprocessing) leaves no file."""
|
|
97
|
+
self.start_event = event
|
|
98
|
+
self.header = self.line(self.redact_fragment(event))
|
|
99
|
+
self._start_thread()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def active(self):
|
|
103
|
+
"""Whether this process has written, or queued, anything beyond capture-start."""
|
|
104
|
+
return self.start_event is not None and (self.header is None or len(self.queue) > 0)
|
|
105
|
+
|
|
106
|
+
def write(self, line):
|
|
107
|
+
self.queue.append(line)
|
|
108
|
+
|
|
109
|
+
def _format(self, parts):
|
|
110
|
+
"""Lines as they are; the capture's tuples as the lines of src/runtime.mjs."""
|
|
111
|
+
prefix, ids = self.prefix, self.id_prefix
|
|
112
|
+
lines = []
|
|
113
|
+
for item in parts:
|
|
114
|
+
if item.__class__ is str:
|
|
115
|
+
lines.append(item)
|
|
116
|
+
elif item[0] == 1: # (1, sequence, time, node, parent, request, metadata)
|
|
117
|
+
_, sequence, at, node, parent, request, info = item
|
|
118
|
+
lines.append('%s%d,"timeNs":%d,"type":"enter","id":%s%d","parentId":%s,"requestId":%s,%s}\n' % (
|
|
119
|
+
prefix, sequence, at, ids, node, 'null' if parent is None else '%s%d"' % (ids, parent), request, info))
|
|
120
|
+
else: # (0, sequence, time, node, request, error, duration)
|
|
121
|
+
_, sequence, at, node, request, error, duration = item
|
|
122
|
+
lines.append('%s%d,"timeNs":%d,"type":"exit","id":%s%d","requestId":%s,"error":%s,"durationNs":%d}\n' % (
|
|
123
|
+
prefix, sequence, at, ids, node, request, error, duration))
|
|
124
|
+
return lines
|
|
125
|
+
|
|
126
|
+
def emit(self, event):
|
|
127
|
+
self.queue.append(self.line(self.redact_fragment(event)))
|
|
128
|
+
|
|
129
|
+
def redact_fragment(self, event):
|
|
130
|
+
"""Redacted and serialised once, for metadata repeated in many events."""
|
|
131
|
+
return json.dumps(self.redact(event), ensure_ascii=False, separators=(',', ':'))[1:-1]
|
|
132
|
+
|
|
133
|
+
def _start_thread(self):
|
|
134
|
+
with self.lock:
|
|
135
|
+
if self.thread is None:
|
|
136
|
+
thread = threading.Thread(target=self._loop, name='codetac-writer', daemon=True)
|
|
137
|
+
thread.start()
|
|
138
|
+
self.thread = thread
|
|
139
|
+
|
|
140
|
+
def _loop(self):
|
|
141
|
+
queue, stopping = self.queue, self.stopping
|
|
142
|
+
waited = 0.0
|
|
143
|
+
while not stopping.wait(self.flush_seconds):
|
|
144
|
+
if queue:
|
|
145
|
+
self.flush()
|
|
146
|
+
waited += self.flush_seconds
|
|
147
|
+
if waited >= 0.4 and self.periodic:
|
|
148
|
+
waited = 0.0
|
|
149
|
+
for task in self.periodic:
|
|
150
|
+
try:
|
|
151
|
+
task()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def close(self):
|
|
156
|
+
"""At exit: the thread stops before the interpreter shuts down, then the last flush."""
|
|
157
|
+
self.stopping.set()
|
|
158
|
+
thread = self.thread
|
|
159
|
+
if thread is not None and thread is not threading.current_thread():
|
|
160
|
+
thread.join(2)
|
|
161
|
+
self.flush()
|
|
162
|
+
|
|
163
|
+
def flush(self):
|
|
164
|
+
with self.lock:
|
|
165
|
+
queue = self.queue
|
|
166
|
+
count = len(queue)
|
|
167
|
+
if not count:
|
|
168
|
+
return
|
|
169
|
+
parts = [queue.popleft() for _ in range(count)]
|
|
170
|
+
if self.disabled:
|
|
171
|
+
return
|
|
172
|
+
lines = self._format(parts)
|
|
173
|
+
if self.header is not None:
|
|
174
|
+
lines.insert(0, self.header)
|
|
175
|
+
self.header = None
|
|
176
|
+
data = ''.join(lines).encode('utf-8', 'replace')
|
|
177
|
+
try:
|
|
178
|
+
if not os.path.isdir(self.directory):
|
|
179
|
+
os.makedirs(self.directory, mode=0o700, exist_ok=True)
|
|
180
|
+
fd = os.open(self.file, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
181
|
+
try:
|
|
182
|
+
view = memoryview(data)
|
|
183
|
+
while view:
|
|
184
|
+
view = view[os.write(fd, view):]
|
|
185
|
+
finally:
|
|
186
|
+
os.close(fd)
|
|
187
|
+
except OSError:
|
|
188
|
+
self.disabled = True
|
|
189
|
+
sys.stderr.write('[CodeTAC] Gravação indisponível; a aplicação continua sem captura.\n')
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Signals. The flush runs first, then whatever the app installed. Handlers the
|
|
193
|
+
# app installs later (uvicorn, asyncio, gunicorn...) are wrapped the same way
|
|
194
|
+
# by signal.signal, and signal.getsignal still returns the app's own. With no
|
|
195
|
+
# handler of the app, the default termination follows: the signal is raised
|
|
196
|
+
# again with SIG_DFL.
|
|
197
|
+
SIGNALS = ('SIGINT', 'SIGTERM', 'SIGHUP')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def install_signals(flush):
|
|
201
|
+
if threading.current_thread() is not threading.main_thread():
|
|
202
|
+
return
|
|
203
|
+
numbers = set(getattr(signal, name) for name in SIGNALS if hasattr(signal, name))
|
|
204
|
+
original_signal = signal.signal
|
|
205
|
+
original_getsignal = signal.getsignal
|
|
206
|
+
|
|
207
|
+
def unwrap(handler):
|
|
208
|
+
return getattr(handler, '__codetac_app_handler__', handler)
|
|
209
|
+
|
|
210
|
+
def wrap(handler):
|
|
211
|
+
def codetac_signal(number, frame):
|
|
212
|
+
flush()
|
|
213
|
+
if callable(handler):
|
|
214
|
+
return handler(number, frame)
|
|
215
|
+
original_signal(number, signal.SIG_DFL)
|
|
216
|
+
os.kill(os.getpid(), number)
|
|
217
|
+
codetac_signal.__codetac_app_handler__ = handler
|
|
218
|
+
return codetac_signal
|
|
219
|
+
|
|
220
|
+
def codetac_signal_signal(number, handler):
|
|
221
|
+
if number in numbers and handler != signal.SIG_IGN and not hasattr(handler, '__codetac_app_handler__'):
|
|
222
|
+
handler = wrap(handler)
|
|
223
|
+
return unwrap(original_signal(number, handler))
|
|
224
|
+
|
|
225
|
+
def codetac_getsignal(number):
|
|
226
|
+
return unwrap(original_getsignal(number))
|
|
227
|
+
|
|
228
|
+
codetac_signal_signal.__doc__ = original_signal.__doc__
|
|
229
|
+
codetac_getsignal.__doc__ = original_getsignal.__doc__
|
|
230
|
+
for number in numbers:
|
|
231
|
+
current = original_getsignal(number)
|
|
232
|
+
# An ignored signal stays ignored (nohup); a handler set outside
|
|
233
|
+
# Python (None) is left alone.
|
|
234
|
+
if current is not None and current != signal.SIG_IGN:
|
|
235
|
+
original_signal(number, wrap(current))
|
|
236
|
+
signal.signal = codetac_signal_signal
|
|
237
|
+
signal.getsignal = codetac_getsignal
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""What the codetac command needs to know about a project's Python (Stage 12):
|
|
2
|
+
its version, the servers and frameworks it can import, and the declared
|
|
3
|
+
dependencies that are not installed. Run with the project's interpreter:
|
|
4
|
+
|
|
5
|
+
<python> probe.py <project folder>
|
|
6
|
+
|
|
7
|
+
Prints one JSON object. Imports nothing of the project: modules are looked up
|
|
8
|
+
with importlib.util.find_spec and distributions with importlib.metadata.
|
|
9
|
+
Keep it runnable on old Pythons (3.8+): below the minimum the command still
|
|
10
|
+
explains the minimal mode.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
MODULES = ['fastapi', 'fastapi_cli', 'uvicorn', 'flask', 'gunicorn', 'hypercorn', 'granian', 'waitress', 'starlette']
|
|
18
|
+
NAME = re.compile(r'^\s*([A-Za-z0-9][A-Za-z0-9._-]*)')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalise(name):
|
|
22
|
+
return re.sub(r'[-_.]+', '-', name).lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pyproject_dependencies(folder):
|
|
26
|
+
"""The [project] dependencies of pyproject.toml, as written (tomllib: 3.11+)."""
|
|
27
|
+
try:
|
|
28
|
+
import tomllib
|
|
29
|
+
with open(os.path.join(folder, 'pyproject.toml'), 'rb') as file:
|
|
30
|
+
project = tomllib.load(file).get('project') or {}
|
|
31
|
+
return [str(item) for item in project.get('dependencies') or []]
|
|
32
|
+
except Exception:
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def requirement_names(folder):
|
|
37
|
+
"""Names from requirements*.txt and pyproject.toml ([project] dependencies)."""
|
|
38
|
+
names = []
|
|
39
|
+
for file in sorted(os.listdir(folder)):
|
|
40
|
+
if not re.match(r'^requirements.*\.txt$', file) or re.search(r'(dev|test|lint|doc)', file):
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
lines = open(os.path.join(folder, file), encoding='utf-8').read().splitlines()
|
|
44
|
+
except (OSError, UnicodeDecodeError):
|
|
45
|
+
continue
|
|
46
|
+
for line in lines:
|
|
47
|
+
line = line.split('#', 1)[0].strip()
|
|
48
|
+
# Options (-r, -e, --index-url) and URLs are not names.
|
|
49
|
+
if not line or line.startswith('-') or '://' in line:
|
|
50
|
+
continue
|
|
51
|
+
match = NAME.match(line)
|
|
52
|
+
if match:
|
|
53
|
+
names.append(match.group(1))
|
|
54
|
+
for item in pyproject_dependencies(folder):
|
|
55
|
+
match = NAME.match(item)
|
|
56
|
+
if match:
|
|
57
|
+
names.append(match.group(1))
|
|
58
|
+
return names
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
folder = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
|
|
63
|
+
import importlib.util
|
|
64
|
+
found = []
|
|
65
|
+
for module in MODULES:
|
|
66
|
+
try:
|
|
67
|
+
if importlib.util.find_spec(module) is not None:
|
|
68
|
+
found.append(module)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
# `python -m fastapi dev` needs fastapi/__main__.py (FastAPI 0.111+), not only fastapi_cli.
|
|
72
|
+
if 'fastapi' in found:
|
|
73
|
+
try:
|
|
74
|
+
spec = importlib.util.find_spec('fastapi')
|
|
75
|
+
locations = list(spec.submodule_search_locations or [])
|
|
76
|
+
if any(os.path.exists(os.path.join(location, '__main__.py')) for location in locations):
|
|
77
|
+
found.append('fastapi.__main__')
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
missing = []
|
|
81
|
+
try:
|
|
82
|
+
from importlib import metadata
|
|
83
|
+
installed = set(normalise(dist.metadata['Name'] or '') for dist in metadata.distributions())
|
|
84
|
+
for name in requirement_names(folder):
|
|
85
|
+
if normalise(name) not in installed and name not in missing:
|
|
86
|
+
missing.append(name)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
print(json.dumps({
|
|
90
|
+
'version': '%d.%d.%d' % sys.version_info[:3],
|
|
91
|
+
'executable': sys.executable,
|
|
92
|
+
'virtual': sys.prefix != getattr(sys, 'base_prefix', sys.prefix),
|
|
93
|
+
'modules': found,
|
|
94
|
+
'missing': missing,
|
|
95
|
+
'pyproject': pyproject_dependencies(folder),
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
main()
|
package/src/recording.mjs
CHANGED
|
@@ -47,14 +47,26 @@ export function createSummary() {
|
|
|
47
47
|
const summary = {
|
|
48
48
|
starts: [], ports: new Set(), addresses: new Map(), pages: 0, requests: 0, withFunctions: new Set(), withBoundaries: new Set(),
|
|
49
49
|
actions: new Set(), files: 0, functions: 0, emptyFiles: 0, failed: [], limitations: new Map(), processes: new Set(),
|
|
50
|
-
firstAction: null, firstRequest: null, firstPage: null, pagePath: null,
|
|
50
|
+
firstAction: null, firstRequest: null, firstPage: null, pagePath: null, servers: new Set(), supervisors: new Set(),
|
|
51
51
|
};
|
|
52
|
+
// Modules per process: a supervisor (the reloader of flask run --debug, fastapi dev)
|
|
53
|
+
// imports the app too, and its modules would count twice.
|
|
54
|
+
const modules = new Map();
|
|
52
55
|
summary.add = event => {
|
|
53
56
|
if (event.process) summary.processes.add(event.process);
|
|
54
57
|
switch (event.type) {
|
|
55
|
-
case 'capture-start': summary.starts.push({ level: event.level, reason: event.reason, root: event.root, node: event.node }); break;
|
|
58
|
+
case 'capture-start': summary.starts.push({ level: event.level, reason: event.reason, root: event.root, node: event.node, python: event.python, process: event.process }); break;
|
|
56
59
|
case 'listening':
|
|
57
60
|
if (event.port) { summary.ports.add(event.port); summary.addresses.set(event.port, event.address); }
|
|
61
|
+
if (event.server) summary.servers.add(event.server);
|
|
62
|
+
break;
|
|
63
|
+
case 'process':
|
|
64
|
+
if (event.role === 'supervisor' && !summary.supervisors.has(event.process)) {
|
|
65
|
+
summary.supervisors.add(event.process);
|
|
66
|
+
const own = modules.get(event.process);
|
|
67
|
+
if (own) { summary.files -= own.files; summary.functions -= own.functions; summary.emptyFiles -= own.empty; }
|
|
68
|
+
if (event.server) summary.servers.add(event.server);
|
|
69
|
+
}
|
|
58
70
|
break;
|
|
59
71
|
case 'page': summary.pages++; summary.pagePath = event.path; break;
|
|
60
72
|
case 'request':
|
|
@@ -67,12 +79,18 @@ export function createSummary() {
|
|
|
67
79
|
if (!summary.actions.has(event.actionId)) summary.firstAction ??= event;
|
|
68
80
|
summary.actions.add(event.actionId);
|
|
69
81
|
break;
|
|
70
|
-
case 'module':
|
|
71
|
-
if (event.cached || isLibraryFile(event.file ?? '')) break;
|
|
82
|
+
case 'module': {
|
|
83
|
+
if (event.cached || isLibraryFile(event.file ?? '') || summary.supervisors.has(event.process)) break;
|
|
72
84
|
summary.files++;
|
|
73
85
|
summary.functions += event.functions ?? 0;
|
|
74
86
|
if (!event.functions) summary.emptyFiles++;
|
|
87
|
+
const own = modules.get(event.process) ?? { files: 0, functions: 0, empty: 0 };
|
|
88
|
+
own.files++;
|
|
89
|
+
own.functions += event.functions ?? 0;
|
|
90
|
+
if (!event.functions) own.empty++;
|
|
91
|
+
modules.set(event.process, own);
|
|
75
92
|
break;
|
|
93
|
+
}
|
|
76
94
|
case 'limitation':
|
|
77
95
|
summary.limitations.set(event.reason, (summary.limitations.get(event.reason) ?? 0) + 1);
|
|
78
96
|
if (event.reason === 'module-transform-failed' || event.reason === 'source-map-unreadable') summary.failed.push({ file: event.file, detail: event.detail });
|
package/src/runtime.mjs
CHANGED
|
@@ -195,8 +195,11 @@ export function createRuntime(directory, { flushBytes = FLUSH_BYTES, flushMs = F
|
|
|
195
195
|
const parent = context.getStore();
|
|
196
196
|
const requestId = `${processId}:r${++requests}`;
|
|
197
197
|
const { path, queryKeys } = splitUrl(req.url ?? '/');
|
|
198
|
+
// A page of another origin calling this server (no proxy): origin and host
|
|
199
|
+
// (scheme, name and port only) let the store make a probable link.
|
|
200
|
+
const cross = crossOrigin(req.headers?.origin, req.headers?.host);
|
|
198
201
|
emit({ type: 'request', requestId, parentId: current().parentId, method: req.method, path, queryKeys, at: Date.now(),
|
|
199
|
-
action: action?.action, actionRequest: action?.actionRequest ?? undefined });
|
|
202
|
+
action: action?.action, actionRequest: action?.actionRequest ?? undefined, origin: cross?.origin, host: cross?.host });
|
|
200
203
|
const start = process.hrtime.bigint();
|
|
201
204
|
let done = false;
|
|
202
205
|
// Cookies the response sets or deletes: names only, never values. Headers
|
|
@@ -268,6 +271,15 @@ export function cookieNames(header, written) {
|
|
|
268
271
|
return [...result.values()];
|
|
269
272
|
}
|
|
270
273
|
|
|
274
|
+
// Same rule as cross_origin in src/python/codetac_py/servers.py.
|
|
275
|
+
export function crossOrigin(origin, host) {
|
|
276
|
+
if (typeof origin !== 'string' || typeof host !== 'string' || origin === 'null') return null;
|
|
277
|
+
let url;
|
|
278
|
+
try { url = new URL(origin); } catch { return null; }
|
|
279
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.host || url.host === host) return null;
|
|
280
|
+
return { origin: `${url.protocol}//${url.host}`, host };
|
|
281
|
+
}
|
|
282
|
+
|
|
271
283
|
// A readable copy of a value for the detail view: plain JSON, bounded in
|
|
272
284
|
// depth, length and size. Getters are not called (they could change state);
|
|
273
285
|
// class instances keep their class name. Redaction happens when it is emitted.
|
package/src/sentences.mjs
CHANGED
|
@@ -40,6 +40,8 @@ const PT = {
|
|
|
40
40
|
// Funções
|
|
41
41
|
calls: items => `chama ${items}`,
|
|
42
42
|
noBoundary: 'Trabalho interno: nenhuma fronteira observada',
|
|
43
|
+
opaque: 'Não observável: corre em código compilado ou gerado que o CodeTAC não consegue ver por dentro',
|
|
44
|
+
templatePart: file => `monta HTML a partir de ${file}`,
|
|
43
45
|
error: 'terminou com erro',
|
|
44
46
|
unfinished: 'não terminou durante a gravação',
|
|
45
47
|
dbSetupPart: n => `executa ${n} comandos de estrutura da base de dados`,
|
|
@@ -117,6 +119,8 @@ const EN = {
|
|
|
117
119
|
auth: (provider, operation) => `Authentication with ${provider}${operation ? `: ${operation}` : ''}`,
|
|
118
120
|
calls: items => `calls ${items}`,
|
|
119
121
|
noBoundary: 'Internal work: no boundary observed',
|
|
122
|
+
opaque: 'Not observable: runs in compiled or generated code that CodeTAC cannot see into',
|
|
123
|
+
templatePart: file => `renders HTML from ${file}`,
|
|
120
124
|
error: 'ended with an error',
|
|
121
125
|
unfinished: 'did not finish during the recording',
|
|
122
126
|
dbSetupPart: n => `runs ${n} database structure commands`,
|
package/src/store.mjs
CHANGED
|
@@ -19,6 +19,15 @@ export function actionLabel(trigger) {
|
|
|
19
19
|
return `${kind}${text ? ` «${text}»` : ''}${suffix}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// The deepest folder that holds both paths (absolute, "/"-separated).
|
|
23
|
+
export function commonFolder(a, b) {
|
|
24
|
+
const left = a.split('/');
|
|
25
|
+
const right = b.split('/');
|
|
26
|
+
let index = 0;
|
|
27
|
+
while (index < left.length && index < right.length && left[index] === right[index]) index++;
|
|
28
|
+
return left.slice(0, index).join('/') || '/';
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
23
32
|
const directory = resolve(folder);
|
|
24
33
|
// A new installation has no data folder yet (~/.codetac).
|
|
@@ -45,6 +54,11 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
45
54
|
if (!columns.has('cookies')) db.exec('alter table requests add column cookies text;');
|
|
46
55
|
// Phase 5: the request that loaded a page (it received the bar).
|
|
47
56
|
if (!columns.has('document')) db.exec('alter table requests add column document integer;');
|
|
57
|
+
// Python stage 9: where the request ended in its process, to tell the work
|
|
58
|
+
// done after the response (background tasks) from the rest.
|
|
59
|
+
if (!columns.has('end_sequence')) db.exec('alter table requests add column end_sequence integer;');
|
|
60
|
+
// Python stage 10 (DP3): requests from a page of another origin, for the probable link.
|
|
61
|
+
if (!columns.has('origin')) db.exec('alter table requests add column origin text; alter table requests add column host text;');
|
|
48
62
|
db.exec('create index if not exists requests_action on requests(action)');
|
|
49
63
|
// Phase 5: why a recording is in minimal mode.
|
|
50
64
|
if (!db.prepare('pragma table_info(runs)').all().some(column => column.name === 'reason')) db.exec('alter table runs add column reason text;');
|
|
@@ -65,10 +79,12 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
65
79
|
const statements = {
|
|
66
80
|
offset: db.prepare('select offset from ingested where file = ?'),
|
|
67
81
|
setOffset: db.prepare('insert into ingested(file, offset) values (?, ?) on conflict(file) do update set offset = excluded.offset'),
|
|
68
|
-
run: db.prepare('insert into runs(run, root, node, level, reason) values (?, ?, ?, ?, ?) on conflict(run) do update set root = excluded.root, node = excluded.node, level = excluded.level, reason = excluded.reason'),
|
|
69
|
-
|
|
82
|
+
run: db.prepare('insert into runs(run, root, node, level, reason) values (?, ?, ?, ?, ?) on conflict(run) do update set root = excluded.root, node = coalesce(excluded.node, runs.node), level = excluded.level, reason = excluded.reason'),
|
|
83
|
+
runRoot: db.prepare('select root from runs where run = ?'),
|
|
84
|
+
probable: db.prepare('select request_id, method, at from requests where action is null and origin = ? and host = ? and path = ? and at between ? and ? order by at'),
|
|
85
|
+
request: db.prepare('insert or ignore into requests(request_id, run, process, method, path, query_keys, at, action, action_request, origin, host) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'),
|
|
70
86
|
action: db.prepare('insert or replace into actions(action_id, segment, run, at, data) values (?, ?, ?, ?, ?)'),
|
|
71
|
-
requestEnd: db.prepare('update requests set status = ?, duration_ns = ?, aborted = ?, cookies = ?, ended = 1 where request_id = ?'),
|
|
87
|
+
requestEnd: db.prepare('update requests set status = ?, duration_ns = ?, aborted = ?, cookies = ?, ended = 1, end_sequence = ? where request_id = ? and process = ?'),
|
|
72
88
|
event: db.prepare('insert or ignore into events(request_id, process, sequence, type, id, data) values (?, ?, ?, ?, ?, ?)'),
|
|
73
89
|
limitation: db.prepare('insert or ignore into limitations(run, file, reason, line, detail) values (?, ?, ?, ?, ?)'),
|
|
74
90
|
document: db.prepare('update requests set document = 1 where request_id = ?'),
|
|
@@ -97,17 +113,24 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
97
113
|
let event;
|
|
98
114
|
try { event = JSON.parse(line); } catch { continue; }
|
|
99
115
|
switch (event.type) {
|
|
100
|
-
case 'capture-start':
|
|
116
|
+
case 'capture-start': {
|
|
117
|
+
// Several processes may share a recording (a Vite frontend and a
|
|
118
|
+
// Python API): the root is the folder common to all of theirs.
|
|
119
|
+
const known = statements.runRoot.get(run)?.root;
|
|
120
|
+
statements.run.run(run, known && event.root ? commonFolder(known, event.root) : event.root ?? known ?? null,
|
|
121
|
+
event.node ?? null, event.level ?? null, event.reason ?? null);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
101
124
|
case 'page': pages.set(event.process, event.path); break;
|
|
102
125
|
case 'request':
|
|
103
126
|
if (pages.get(event.process) === event.path) { pages.delete(event.process); documents.add(event.requestId); }
|
|
104
127
|
statements.request.run(event.requestId, run, event.process, event.method, event.path,
|
|
105
|
-
JSON.stringify(event.queryKeys ?? []), event.at ?? null, event.action ?? null, event.actionRequest ?? null);
|
|
128
|
+
JSON.stringify(event.queryKeys ?? []), event.at ?? null, event.action ?? null, event.actionRequest ?? null, event.origin ?? null, event.host ?? null);
|
|
106
129
|
if (documents.has(event.requestId)) statements.document.run(event.requestId);
|
|
107
130
|
break;
|
|
108
131
|
case 'browser-action': statements.action.run(event.actionId, event.segment ?? 1, run, event.startedAt ?? null, line); break;
|
|
109
132
|
case 'request-end': statements.requestEnd.run(event.status ?? null, event.durationNs ?? null, event.aborted ? 1 : 0,
|
|
110
|
-
event.cookies ? JSON.stringify(event.cookies) : null, event.requestId); break;
|
|
133
|
+
event.cookies ? JSON.stringify(event.cookies) : null, event.sequence ?? null, event.requestId, event.process); break;
|
|
111
134
|
case 'limitation': statements.limitation.run(run, event.file ?? '', event.reason ?? '', event.line ?? null, event.detail ?? null); break;
|
|
112
135
|
case 'enter': case 'exit': case 'boundary': case 'boundary-end': case 'detail':
|
|
113
136
|
if (event.requestId) statements.event.run(event.requestId, event.process, event.sequence, event.type, event.id, line);
|
|
@@ -189,6 +212,25 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
189
212
|
const timeline = [];
|
|
190
213
|
const take = number => server.filter(item => item.number === number && !used.has(item.dossier.request.requestId))
|
|
191
214
|
.map(item => { used.add(item.dossier.request.requestId); return item.dossier; });
|
|
215
|
+
// DP3 (b): a request of the page to another origin (the API on another
|
|
216
|
+
// port, without a proxy) cannot carry the action without changing the
|
|
217
|
+
// app (CORS). It is linked, as probable, to the request that server got
|
|
218
|
+
// from this page's origin, for the same host, method and path, while the
|
|
219
|
+
// browser waited for it; the CORS preflight (OPTIONS) goes with it.
|
|
220
|
+
const claimed = new Set();
|
|
221
|
+
const linked = new Set(); // browser requests with a probable link: the app's own API, not an outside service
|
|
222
|
+
const probable = (segment, request) => {
|
|
223
|
+
if (request.sameOrigin || !request.host || !segment.origin || segment.startedAt == null || request.startMs == null) return [];
|
|
224
|
+
const start = segment.startedAt + request.startMs;
|
|
225
|
+
const end = start + (request.durationMs ?? 0);
|
|
226
|
+
const rows = statements.probable.all(segment.origin, request.host, request.path ?? '', start - 250, end + 250)
|
|
227
|
+
.filter(row => !claimed.has(row.request_id));
|
|
228
|
+
const main = rows.filter(row => row.method === request.method).sort((a, b) => Math.abs(a.at - start) - Math.abs(b.at - start))[0];
|
|
229
|
+
if (!main) return [];
|
|
230
|
+
const preflight = rows.find(row => row.method === 'OPTIONS' && row.at <= main.at);
|
|
231
|
+
linked.add(request);
|
|
232
|
+
return [preflight, main].filter(Boolean).map(row => { claimed.add(row.request_id); return dossier(row.request_id); });
|
|
233
|
+
};
|
|
192
234
|
segments.forEach((segment, index) => {
|
|
193
235
|
if (index === 0 && segment.trigger) timeline.push({ type: 'trigger', segment: segment.segment, page: segment.page, trigger: segment.trigger });
|
|
194
236
|
if (index > 0 || !segment.trigger) {
|
|
@@ -196,7 +238,12 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
196
238
|
timeline.push({ type: 'document', segment: segment.segment, page: segment.page, server: take(null) });
|
|
197
239
|
}
|
|
198
240
|
const events = [
|
|
199
|
-
...(segment.requests ?? []).map(request =>
|
|
241
|
+
...(segment.requests ?? []).map(request => {
|
|
242
|
+
const server = take(request.n);
|
|
243
|
+
const guessed = server.length ? [] : probable(segment, request);
|
|
244
|
+
return { at: request.startMs ?? 0, item: { type: 'request', segment: segment.segment, browser: request, server: server.length ? server : guessed,
|
|
245
|
+
...(guessed.length ? { probable: true } : {}) } };
|
|
246
|
+
}),
|
|
200
247
|
...(segment.navigations ?? []).filter(item => item.kind !== 'documento')
|
|
201
248
|
.map(navigation => ({ at: navigation.atMs ?? 0, item: { type: 'navigation', segment: segment.segment, ...navigation } })),
|
|
202
249
|
].sort((a, b) => a.at - b.at);
|
|
@@ -215,7 +262,7 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
215
262
|
const found = combined.find(entry => entry.key === mark.key);
|
|
216
263
|
if (found) found.count += mark.count; else combined.push({ ...mark });
|
|
217
264
|
}
|
|
218
|
-
for (const request of segments.flatMap(segment => segment.requests ?? []).filter(request => !request.sameOrigin && request.host)) {
|
|
265
|
+
for (const request of segments.flatMap(segment => segment.requests ?? []).filter(request => !request.sameOrigin && request.host && !linked.has(request))) {
|
|
219
266
|
const key = `browser:${request.host}`;
|
|
220
267
|
const found = combined.find(entry => entry.key === key);
|
|
221
268
|
if (found) found.count++; else combined.push({ key, count: 1, label: `chamada do browser a ${request.host}` });
|
|
@@ -260,6 +307,10 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
260
307
|
const starts = events.filter(event => event.type === 'enter' || event.type === 'boundary');
|
|
261
308
|
const ids = new Set(starts.map(event => event.id));
|
|
262
309
|
const depth = new Map();
|
|
310
|
+
// After the response: marked by the capture, or recorded after the
|
|
311
|
+
// request's end in its process (the order inside a process is exact).
|
|
312
|
+
const after = event => Boolean(event.afterResponse)
|
|
313
|
+
|| (row.end_sequence != null && event.process === row.process && event.sequence > row.end_sequence);
|
|
263
314
|
const steps = starts.map(event => {
|
|
264
315
|
const level = event.parentId && ids.has(event.parentId) ? (depth.get(event.parentId) ?? 0) + 1 : 0;
|
|
265
316
|
depth.set(event.id, level);
|
|
@@ -270,11 +321,12 @@ export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
|
270
321
|
const detail = recordedValues.get(event.id);
|
|
271
322
|
return { ...common, type: 'function', function: event.function, file: event.file, line: event.line,
|
|
272
323
|
endLine: event.endLine ?? null, mapped: event.mapped, async: event.async,
|
|
324
|
+
opaque: event.opaque || undefined, afterResponse: after(event) || undefined,
|
|
273
325
|
...(detail ? { detail: { args: detail.args, lines: detail.lines, ...('returned' in detail ? { returned: detail.returned } : { threw: detail.threw }) } } : {}) };
|
|
274
326
|
}
|
|
275
327
|
const { type, id, parentId, requestId: _r, process, sequence, timeNs, version, ...details } = event;
|
|
276
328
|
const { type: _t, id: _i, requestId: _q, durationNs: _d, error: _e, process: _p, sequence: _s, timeNs: _n, version: _v, ...result } = end ?? {};
|
|
277
|
-
const step = { ...common, type: 'boundary', ...details, result };
|
|
329
|
+
const step = { ...common, type: 'boundary', ...details, afterResponse: after(event) || undefined, result };
|
|
278
330
|
if (step.kind === 'ia') step.costUsd = estimateCost(result.model ?? step.model, result.usage);
|
|
279
331
|
return step;
|
|
280
332
|
});
|