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.
@@ -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
- request: db.prepare('insert or ignore into requests(request_id, run, process, method, path, query_keys, at, action, action_request) values (?, ?, ?, ?, ?, ?, ?, ?, ?)'),
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': statements.run.run(run, event.root ?? null, event.node ?? null, event.level ?? null, event.reason ?? null); break;
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 => ({ at: request.startMs ?? 0, item: { type: 'request', segment: segment.segment, browser: request, server: take(request.n) } })),
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
  });