@descryy/runtime-adapter-python 0.0.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/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/python-frame-parsing.d.ts +90 -0
- package/dist/python-frame-parsing.d.ts.map +1 -0
- package/dist/python-frame-parsing.js +259 -0
- package/dist/python-frame-parsing.js.map +1 -0
- package/dist/python-log-envelopes.d.ts +76 -0
- package/dist/python-log-envelopes.d.ts.map +1 -0
- package/dist/python-log-envelopes.js +129 -0
- package/dist/python-log-envelopes.js.map +1 -0
- package/dist/python-runtime-adapter.d.ts +45 -0
- package/dist/python-runtime-adapter.d.ts.map +1 -0
- package/dist/python-runtime-adapter.js +52 -0
- package/dist/python-runtime-adapter.js.map +1 -0
- package/dist/python-source-location-resolver.d.ts +33 -0
- package/dist/python-source-location-resolver.d.ts.map +1 -0
- package/dist/python-source-location-resolver.js +49 -0
- package/dist/python-source-location-resolver.js.map +1 -0
- package/dist/python-stack-trace-parser.d.ts +27 -0
- package/dist/python-stack-trace-parser.d.ts.map +1 -0
- package/dist/python-stack-trace-parser.js +75 -0
- package/dist/python-stack-trace-parser.js.map +1 -0
- package/package.json +30 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { RawPythonFrame } from "./python-frame-parsing.ts";
|
|
2
|
+
export { CHAINED_EXCEPTION_CONNECTIVES, createPythonLineClassifier, isPythonBannerLine, isPythonLocationLine, isPythonTrailerLine, looksLikeContinuation, parsePythonFrame, } from "./python-frame-parsing.ts";
|
|
3
|
+
export { createUvicornLogEnvelope } from "./python-log-envelopes.ts";
|
|
4
|
+
export { createPythonSourceLocationResolver } from "./python-source-location-resolver.ts";
|
|
5
|
+
export { createPythonStackTraceParser } from "./python-stack-trace-parser.ts";
|
|
6
|
+
export type { PythonRuntimeAdapterOptions } from "./python-runtime-adapter.ts";
|
|
7
|
+
export { createPythonRuntimeAdapter } from "./python-runtime-adapter.ts";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EACL,6BAA6B,EAC7B,0BAA0B,EAC1B,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EAAE,kCAAkC,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAC9E,YAAY,EAAE,2BAA2B,EAAE,MAAM,6BAA6B,CAAC;AAC/E,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { CHAINED_EXCEPTION_CONNECTIVES, createPythonLineClassifier, isPythonBannerLine, isPythonLocationLine, isPythonTrailerLine, looksLikeContinuation, parsePythonFrame, } from "./python-frame-parsing.js";
|
|
2
|
+
export { createUvicornLogEnvelope } from "./python-log-envelopes.js";
|
|
3
|
+
export { createPythonSourceLocationResolver } from "./python-source-location-resolver.js";
|
|
4
|
+
export { createPythonStackTraceParser } from "./python-stack-trace-parser.js";
|
|
5
|
+
export { createPythonRuntimeAdapter } from "./python-runtime-adapter.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,6BAA6B,EAC7B,0BAA0B,EAC1B,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EAAE,kCAAkC,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAE9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CPython traceback line shapes — the `LineClassifier` for
|
|
3
|
+
* `ExceptionBlockDetector` plus the raw-text-to-frame-parts extraction that
|
|
4
|
+
* feeds `SourceLocationResolver`.
|
|
5
|
+
*
|
|
6
|
+
* CPython's format differs from V8's in three ways that matter, and each one
|
|
7
|
+
* is a place a naive port of the V8 classifier would fail quietly rather
|
|
8
|
+
* than loudly:
|
|
9
|
+
*
|
|
10
|
+
* 1. **The opening line names no exception.** `Traceback (most recent call
|
|
11
|
+
* last):` is a fixed banner; the type and message arrive *after* every
|
|
12
|
+
* frame. That is what `isPythonTrailerLine` exists for — see
|
|
13
|
+
* `LineClassifier.isTrailer`.
|
|
14
|
+
* 2. **Frames print oldest-call-first.** The failing frame is last, the
|
|
15
|
+
* opposite of V8. The reversal is done in the parser, not here, because
|
|
16
|
+
* `StackTrace.frames` is contractually most-recent-first.
|
|
17
|
+
* 3. **A frame is more than one line.** The `File "...", line N, in f`
|
|
18
|
+
* line is followed by the source text, and on 3.11+ by a caret line
|
|
19
|
+
* (` ^^^^^^`) marking the failing expression. Those continuation
|
|
20
|
+
* lines carry no location of their own but belong to the block, so
|
|
21
|
+
* `isPythonFrameLine` accepts them — otherwise the first source line
|
|
22
|
+
* would terminate the block and truncate every traceback to one frame.
|
|
23
|
+
*
|
|
24
|
+
* The banner test is deliberately not anchored to the start of the line, for
|
|
25
|
+
* the same reason the V8 header test isn't: real applications log through
|
|
26
|
+
* formatters that prefix timestamps and levels. The block detector's own
|
|
27
|
+
* "banner immediately followed by a frame" rule is what keeps that safe — a
|
|
28
|
+
* log line merely mentioning the word with nothing frame-shaped after it
|
|
29
|
+
* never becomes an exception.
|
|
30
|
+
*/
|
|
31
|
+
export interface RawPythonFrame {
|
|
32
|
+
/** `<module>`, `<lambda>` and `<listcomp>` are real CPython function names and are kept verbatim, not nulled. */
|
|
33
|
+
readonly functionName: string | null;
|
|
34
|
+
/** Verbatim, including CPython's pseudo-files: `<string>`, `<stdin>`, `<frozen importlib._bootstrap>`. */
|
|
35
|
+
readonly file: string;
|
|
36
|
+
readonly line: number;
|
|
37
|
+
}
|
|
38
|
+
/** Removes PEP 654's tree gutter, if present. Identity for an ordinary traceback line. */
|
|
39
|
+
export declare function stripExceptionGroupGutter(line: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Two connective lines CPython prints *between* chained tracebacks. They are
|
|
42
|
+
* deliberately NOT trailers: each chained traceback is its own complete
|
|
43
|
+
* exception block, and swallowing the connective line into the first block
|
|
44
|
+
* would attach the wrong text to it. They fall through as plain log lines,
|
|
45
|
+
* and the second `Traceback` banner opens a second block — which reports
|
|
46
|
+
* chained exceptions as two honest blocks plus the sentence linking them,
|
|
47
|
+
* rather than one block silently missing half its content.
|
|
48
|
+
*/
|
|
49
|
+
export declare const CHAINED_EXCEPTION_CONNECTIVES: readonly ["During handling of the above exception, another exception occurred:", "The above exception was the direct cause of the following exception:"];
|
|
50
|
+
export declare function isPythonBannerLine(line: string): boolean;
|
|
51
|
+
export declare function isPythonLocationLine(line: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Indentation alone — **not** sufficient on its own to call a line a frame.
|
|
54
|
+
* See `createPythonLineClassifier`, which is what the adapter actually uses.
|
|
55
|
+
*/
|
|
56
|
+
export declare function looksLikeContinuation(line: string): boolean;
|
|
57
|
+
export declare function isPythonTrailerLine(line: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* A stateful `LineClassifier`, and the statefulness is the point.
|
|
60
|
+
*
|
|
61
|
+
* The obvious stateless rule — "a frame is a `File` line **or** anything
|
|
62
|
+
* indented" — is wrong, and the cross-language control in this package's
|
|
63
|
+
* tests is what caught it. `ExceptionBlockDetector` opens a block the
|
|
64
|
+
* moment `isFrame` returns true from idle, so a stateless indent rule turns
|
|
65
|
+
* **every indented log line in the application's output** into the start of
|
|
66
|
+
* a spurious stack-trace block: a pretty-printed JSON payload, a wrapped
|
|
67
|
+
* message, an indented SQL statement. It also accepts another runtime's
|
|
68
|
+
* frames verbatim (` at deleteInvoice (file:///app/db.js:17:22)` is just
|
|
69
|
+
* an indented line), so a mis-wired adapter would half-work rather than
|
|
70
|
+
* fail.
|
|
71
|
+
*
|
|
72
|
+
* A continuation is therefore only recognised **after a `File` line has
|
|
73
|
+
* been seen** — which is the actual rule CPython's format obeys, since
|
|
74
|
+
* source text and caret markers only ever appear beneath a location line.
|
|
75
|
+
* Any line that is neither a location nor a continuation clears that state,
|
|
76
|
+
* so a block that ends without a trailer cannot leave the classifier primed.
|
|
77
|
+
*
|
|
78
|
+
* This depends on lines arriving in stream order, which `ExceptionBlockDetector`
|
|
79
|
+
* provides by construction — it is a one-line-at-a-time streaming detector
|
|
80
|
+
* and `isFrame` is consulted for every line in every state. A fresh
|
|
81
|
+
* classifier per collector keeps two processes' output from sharing state.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createPythonLineClassifier(): {
|
|
84
|
+
isHeader(line: string): boolean;
|
|
85
|
+
isFrame(line: string): boolean;
|
|
86
|
+
isTrailer(line: string): boolean;
|
|
87
|
+
};
|
|
88
|
+
/** `null` for a continuation line, or a `File` line with no parseable line number — never fabricated. */
|
|
89
|
+
export declare function parsePythonFrame(line: string): RawPythonFrame | null;
|
|
90
|
+
//# sourceMappingURL=python-frame-parsing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-frame-parsing.d.ts","sourceRoot":"","sources":["../src/python-frame-parsing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,cAAc;IAC7B,iHAAiH;IACjH,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,0GAA0G;IAC1G,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAyDD,0FAA0F;AAC1F,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9D;AA6ED;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,0JAGhC,CAAC;AAEX,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGzD;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,0BAA0B,IAAI;IAC5C,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CAClC,CA+BA;AAED,yGAAyG;AACzG,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAOpE"}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CPython traceback line shapes — the `LineClassifier` for
|
|
3
|
+
* `ExceptionBlockDetector` plus the raw-text-to-frame-parts extraction that
|
|
4
|
+
* feeds `SourceLocationResolver`.
|
|
5
|
+
*
|
|
6
|
+
* CPython's format differs from V8's in three ways that matter, and each one
|
|
7
|
+
* is a place a naive port of the V8 classifier would fail quietly rather
|
|
8
|
+
* than loudly:
|
|
9
|
+
*
|
|
10
|
+
* 1. **The opening line names no exception.** `Traceback (most recent call
|
|
11
|
+
* last):` is a fixed banner; the type and message arrive *after* every
|
|
12
|
+
* frame. That is what `isPythonTrailerLine` exists for — see
|
|
13
|
+
* `LineClassifier.isTrailer`.
|
|
14
|
+
* 2. **Frames print oldest-call-first.** The failing frame is last, the
|
|
15
|
+
* opposite of V8. The reversal is done in the parser, not here, because
|
|
16
|
+
* `StackTrace.frames` is contractually most-recent-first.
|
|
17
|
+
* 3. **A frame is more than one line.** The `File "...", line N, in f`
|
|
18
|
+
* line is followed by the source text, and on 3.11+ by a caret line
|
|
19
|
+
* (` ^^^^^^`) marking the failing expression. Those continuation
|
|
20
|
+
* lines carry no location of their own but belong to the block, so
|
|
21
|
+
* `isPythonFrameLine` accepts them — otherwise the first source line
|
|
22
|
+
* would terminate the block and truncate every traceback to one frame.
|
|
23
|
+
*
|
|
24
|
+
* The banner test is deliberately not anchored to the start of the line, for
|
|
25
|
+
* the same reason the V8 header test isn't: real applications log through
|
|
26
|
+
* formatters that prefix timestamps and levels. The block detector's own
|
|
27
|
+
* "banner immediately followed by a frame" rule is what keeps that safe — a
|
|
28
|
+
* log line merely mentioning the word with nothing frame-shaped after it
|
|
29
|
+
* never becomes an exception.
|
|
30
|
+
*/
|
|
31
|
+
const BANNER_PATTERN = /Traceback \(most recent call last\):/;
|
|
32
|
+
/**
|
|
33
|
+
* PEP 654's tree gutter, stripped before every other pattern in this file
|
|
34
|
+
* sees a line.
|
|
35
|
+
*
|
|
36
|
+
* ## The measurement
|
|
37
|
+
*
|
|
38
|
+
* `asyncio.TaskGroup` (stdlib since 3.11) and every `ExceptionGroup` print
|
|
39
|
+
* their tracebacks inside a drawn tree:
|
|
40
|
+
*
|
|
41
|
+
* ```
|
|
42
|
+
* + Exception Group Traceback (most recent call last):
|
|
43
|
+
* | File "/app/x.py", line 13, in <module>
|
|
44
|
+
* | asyncio.run(main())
|
|
45
|
+
* | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
|
46
|
+
* +-+---------------- 1 ----------------
|
|
47
|
+
* | Traceback (most recent call last):
|
|
48
|
+
* | File "/app/x.py", line 4, in read_customer_name
|
|
49
|
+
* | TypeError: 'NoneType' object is not subscriptable
|
|
50
|
+
* +------------------------------------
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Every location line is ` | File "..."`, and `FILE_LINE_LOOSE_PATTERN`
|
|
54
|
+
* anchors `^\s*File`. So **not one frame line matched**. Measured through
|
|
55
|
+
* the real detector on real output: 28 lines in, **28 plain logs out** —
|
|
56
|
+
* no EXCEPTION, no STACK_TRACE, no frames, and no degradation reported.
|
|
57
|
+
*
|
|
58
|
+
* Reachable through the front door, not a corner: a real FastAPI service
|
|
59
|
+
* with an async endpoint failing inside a `TaskGroup` produced two genuine
|
|
60
|
+
* `TypeError`s and the pipeline reported **one**. Half the failures silently
|
|
61
|
+
* gone, from a healthy-looking collector.
|
|
62
|
+
*
|
|
63
|
+
* ## Why it was invisible
|
|
64
|
+
*
|
|
65
|
+
* Every Python fixture in this repository raises from a plain synchronous
|
|
66
|
+
* call chain, so nothing was ever grouped. Lane A hit the identical shape on
|
|
67
|
+
* the JVM (RT-094): `isFrame` rejected JPMS module-qualified frames, every
|
|
68
|
+
* stack truncated at its first reflective call, and it was invisible because
|
|
69
|
+
* every JVM fixture threw directly from `main`. **A fixture that exercises
|
|
70
|
+
* only the simple call shape certifies only the simple call shape** — that
|
|
71
|
+
* is a property of the fixture, not of the language, and it has now cost two
|
|
72
|
+
* languages the same way.
|
|
73
|
+
*
|
|
74
|
+
* ## Why stripping is safe here
|
|
75
|
+
*
|
|
76
|
+
* The pattern is deliberately tighter than "any leading pipe": exactly one
|
|
77
|
+
* `|` or `+` followed by exactly one space, repeated for nesting. The
|
|
78
|
+
* separator rules (`+-+------- 1 -------`, `+---------`) have `-` after the
|
|
79
|
+
* `+` and so are never stripped — they stay unrecognised, which is correct,
|
|
80
|
+
* because they are decoration and belong to no frame. A traceback that is
|
|
81
|
+
* not in a group has no gutter and is untouched.
|
|
82
|
+
*/
|
|
83
|
+
const GROUP_GUTTER = /^(?:[ \t]*[|+] )+/;
|
|
84
|
+
/** Removes PEP 654's tree gutter, if present. Identity for an ordinary traceback line. */
|
|
85
|
+
export function stripExceptionGroupGutter(line) {
|
|
86
|
+
return line.replace(GROUP_GUTTER, "");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The two-space-indented location line. `, in <name>` is optional: it is
|
|
90
|
+
* absent for a frame compiled from a bare string, and `line N` is absent in
|
|
91
|
+
* a handful of exotic cases, which is why the line number is required by
|
|
92
|
+
* `parsePythonFrame` but not by the classifier — recognising a line as
|
|
93
|
+
* belonging to the block is a weaker claim than extracting a location from
|
|
94
|
+
* it, and conflating the two loses frames rather than merely their detail.
|
|
95
|
+
*/
|
|
96
|
+
const FILE_LINE_PATTERN = /^\s*File "(?<file>.*)", line (?<line>\d+)(?:, in (?<fn>.*))?\s*$/;
|
|
97
|
+
const FILE_LINE_LOOSE_PATTERN = /^\s*File "(?<file>.*)"/;
|
|
98
|
+
/**
|
|
99
|
+
* A frame's continuation: source text, or a 3.11+ caret marker. Indented
|
|
100
|
+
* strictly deeper than the `File` line's two spaces, which is what keeps a
|
|
101
|
+
* zero-indent trailer (`ValueError: ...`) from being read as one.
|
|
102
|
+
*/
|
|
103
|
+
const CONTINUATION_PATTERN = /^\s{3,}\S/;
|
|
104
|
+
/**
|
|
105
|
+
* The closing line: `ValueError: bad input`, a dotted
|
|
106
|
+
* `myapp.errors.ConfigError: ...`, or a bare `SystemExit` with no message
|
|
107
|
+
* at all. Anchored at zero indent — every frame line in a CPython traceback
|
|
108
|
+
* is indented, so the indent is what separates "this closes the block" from
|
|
109
|
+
* "this is more of the block".
|
|
110
|
+
*
|
|
111
|
+
* ## The lookahead, and the fabrication it removes (RT-106)
|
|
112
|
+
*
|
|
113
|
+
* Without `(?=[\w.]*[a-z])` this pattern was `^[A-Za-z_][\w.]*(?::[ \t].*)?$`
|
|
114
|
+
* — **any** `WORD: text` line at zero indent, which is `logging`'s default
|
|
115
|
+
* format and uvicorn's entire access-log format. A CPython traceback is
|
|
116
|
+
* written one `write()` per line, so a second thread logging to the same fd
|
|
117
|
+
* interleaves *between* them. Measured, on real threaded CPython 3.12
|
|
118
|
+
* output, three consecutive runs of 25 failures each:
|
|
119
|
+
*
|
|
120
|
+
* | | run 1 | run 2 | run 3 |
|
|
121
|
+
* | --- | --- | --- | --- |
|
|
122
|
+
* | failures reported with an access-log line as their identity | 7 | 8 | 7 |
|
|
123
|
+
*
|
|
124
|
+
* And the damage is symmetric, which the first reading of this defect
|
|
125
|
+
* missed. An `INFO:` line landing after the `Traceback` banner closed the
|
|
126
|
+
* block **immediately**, so the emitted EXCEPTION was two lines — the
|
|
127
|
+
* banner and `INFO: 127.0.0.1:50002 - "GET /health HTTP/1.1" 200 OK`, zero
|
|
128
|
+
* frames. *A successful request reported as the failure.* The real frames
|
|
129
|
+
* and the real `TypeError:` then arrived with no header and were emitted as
|
|
130
|
+
* a bare `stack-trace` event, so the genuine failure produced **no
|
|
131
|
+
* EXCEPTION at all.** Roughly 30% of failures, both halves wrong.
|
|
132
|
+
*
|
|
133
|
+
* ## Why a lowercase letter, and what it costs
|
|
134
|
+
*
|
|
135
|
+
* A Python exception class is CapWords by PEP 8 and a log level is
|
|
136
|
+
* upper-case by universal convention, so "the class token contains at least
|
|
137
|
+
* one lowercase letter" separates them. That is a naming convention rather
|
|
138
|
+
* than a grammar, so the cost was **measured before it was accepted**: 199
|
|
139
|
+
* importable stdlib modules were walked and every `BaseException` subclass
|
|
140
|
+
* in them collected — **228 distinct exception class names, 0 of which
|
|
141
|
+
* contain no lowercase letter.** A dotted `myapp.errors.ConfigError`
|
|
142
|
+
* passes on its module path alone.
|
|
143
|
+
*
|
|
144
|
+
* **The disclosed recall cost:** an exception class named entirely in
|
|
145
|
+
* upper-case (`FOO`) no longer closes a block. Its frames still become a
|
|
146
|
+
* `stack-trace` event; what is lost is the exception identity, not the
|
|
147
|
+
* evidence. That is the deliberate direction of the trade — this defect was
|
|
148
|
+
* shipping *wrong* evidence, and a gap is disclosable in a way a
|
|
149
|
+
* confidently-wrong identity is not.
|
|
150
|
+
*
|
|
151
|
+
* **What it does not fix:** a log line whose first token happens to be
|
|
152
|
+
* CapWords — `Error: connection refused` from an application's own `print`
|
|
153
|
+
* — is still trailer-shaped and still interleaves. The structural answer to
|
|
154
|
+
* that is a declared `LogEnvelope`, whose `startsRecord` closes a block at
|
|
155
|
+
* a boundary the framework *states* rather than one this pattern guesses;
|
|
156
|
+
* `createUvicornLogEnvelope` is the first of those. This lookahead is what
|
|
157
|
+
* an **un-enveloped** stream gets, and it is a heuristic, said plainly.
|
|
158
|
+
*/
|
|
159
|
+
const TRAILER_PATTERN = /^(?=[\w.]*[a-z])[A-Za-z_][\w.]*(?::[ \t].*)?$/;
|
|
160
|
+
/**
|
|
161
|
+
* Two connective lines CPython prints *between* chained tracebacks. They are
|
|
162
|
+
* deliberately NOT trailers: each chained traceback is its own complete
|
|
163
|
+
* exception block, and swallowing the connective line into the first block
|
|
164
|
+
* would attach the wrong text to it. They fall through as plain log lines,
|
|
165
|
+
* and the second `Traceback` banner opens a second block — which reports
|
|
166
|
+
* chained exceptions as two honest blocks plus the sentence linking them,
|
|
167
|
+
* rather than one block silently missing half its content.
|
|
168
|
+
*/
|
|
169
|
+
export const CHAINED_EXCEPTION_CONNECTIVES = [
|
|
170
|
+
"During handling of the above exception, another exception occurred:",
|
|
171
|
+
"The above exception was the direct cause of the following exception:",
|
|
172
|
+
];
|
|
173
|
+
export function isPythonBannerLine(line) {
|
|
174
|
+
return BANNER_PATTERN.test(stripExceptionGroupGutter(line));
|
|
175
|
+
}
|
|
176
|
+
export function isPythonLocationLine(line) {
|
|
177
|
+
return FILE_LINE_LOOSE_PATTERN.test(stripExceptionGroupGutter(line));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Indentation alone — **not** sufficient on its own to call a line a frame.
|
|
181
|
+
* See `createPythonLineClassifier`, which is what the adapter actually uses.
|
|
182
|
+
*/
|
|
183
|
+
export function looksLikeContinuation(line) {
|
|
184
|
+
return CONTINUATION_PATTERN.test(stripExceptionGroupGutter(line));
|
|
185
|
+
}
|
|
186
|
+
export function isPythonTrailerLine(line) {
|
|
187
|
+
if (CHAINED_EXCEPTION_CONNECTIVES.some((c) => line.trim() === c))
|
|
188
|
+
return false;
|
|
189
|
+
return TRAILER_PATTERN.test(stripExceptionGroupGutter(line));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* A stateful `LineClassifier`, and the statefulness is the point.
|
|
193
|
+
*
|
|
194
|
+
* The obvious stateless rule — "a frame is a `File` line **or** anything
|
|
195
|
+
* indented" — is wrong, and the cross-language control in this package's
|
|
196
|
+
* tests is what caught it. `ExceptionBlockDetector` opens a block the
|
|
197
|
+
* moment `isFrame` returns true from idle, so a stateless indent rule turns
|
|
198
|
+
* **every indented log line in the application's output** into the start of
|
|
199
|
+
* a spurious stack-trace block: a pretty-printed JSON payload, a wrapped
|
|
200
|
+
* message, an indented SQL statement. It also accepts another runtime's
|
|
201
|
+
* frames verbatim (` at deleteInvoice (file:///app/db.js:17:22)` is just
|
|
202
|
+
* an indented line), so a mis-wired adapter would half-work rather than
|
|
203
|
+
* fail.
|
|
204
|
+
*
|
|
205
|
+
* A continuation is therefore only recognised **after a `File` line has
|
|
206
|
+
* been seen** — which is the actual rule CPython's format obeys, since
|
|
207
|
+
* source text and caret markers only ever appear beneath a location line.
|
|
208
|
+
* Any line that is neither a location nor a continuation clears that state,
|
|
209
|
+
* so a block that ends without a trailer cannot leave the classifier primed.
|
|
210
|
+
*
|
|
211
|
+
* This depends on lines arriving in stream order, which `ExceptionBlockDetector`
|
|
212
|
+
* provides by construction — it is a one-line-at-a-time streaming detector
|
|
213
|
+
* and `isFrame` is consulted for every line in every state. A fresh
|
|
214
|
+
* classifier per collector keeps two processes' output from sharing state.
|
|
215
|
+
*/
|
|
216
|
+
export function createPythonLineClassifier() {
|
|
217
|
+
let sawLocationLine = false;
|
|
218
|
+
return {
|
|
219
|
+
isHeader(line) {
|
|
220
|
+
if (isPythonBannerLine(line)) {
|
|
221
|
+
sawLocationLine = false;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
},
|
|
226
|
+
isFrame(line) {
|
|
227
|
+
if (isPythonLocationLine(line)) {
|
|
228
|
+
sawLocationLine = true;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
if (sawLocationLine && looksLikeContinuation(line))
|
|
232
|
+
return true;
|
|
233
|
+
sawLocationLine = false;
|
|
234
|
+
return false;
|
|
235
|
+
},
|
|
236
|
+
isTrailer(line) {
|
|
237
|
+
// Only closes a block that actually had frames in it — a bare
|
|
238
|
+
// identifier-shaped log line cannot close a block that never opened.
|
|
239
|
+
if (!sawLocationLine && !isPythonTrailerLine(line))
|
|
240
|
+
return false;
|
|
241
|
+
if (!isPythonTrailerLine(line))
|
|
242
|
+
return false;
|
|
243
|
+
sawLocationLine = false;
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** `null` for a continuation line, or a `File` line with no parseable line number — never fabricated. */
|
|
249
|
+
export function parsePythonFrame(line) {
|
|
250
|
+
const match = FILE_LINE_PATTERN.exec(stripExceptionGroupGutter(line));
|
|
251
|
+
if (!match?.groups)
|
|
252
|
+
return null;
|
|
253
|
+
const file = match.groups["file"];
|
|
254
|
+
const lineNumber = Number(match.groups["line"]);
|
|
255
|
+
if (file === undefined || !Number.isInteger(lineNumber))
|
|
256
|
+
return null;
|
|
257
|
+
return { functionName: match.groups["fn"] ?? null, file, line: lineNumber };
|
|
258
|
+
}
|
|
259
|
+
//# sourceMappingURL=python-frame-parsing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-frame-parsing.js","sourceRoot":"","sources":["../src/python-frame-parsing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAUH,MAAM,cAAc,GAAG,sCAAsC,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,MAAM,YAAY,GAAG,mBAAmB,CAAC;AAEzC,0FAA0F;AAC1F,MAAM,UAAU,yBAAyB,CAAC,IAAY;IACpD,OAAO,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,iBAAiB,GAAG,kEAAkE,CAAC;AAC7F,MAAM,uBAAuB,GAAG,wBAAwB,CAAC;AAEzD;;;;GAIG;AACH,MAAM,oBAAoB,GAAG,WAAW,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,eAAe,GAAG,+CAA+C,CAAC;AAExE;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG;IAC3C,qEAAqE;IACrE,sEAAsE;CAC9D,CAAC;AAEX,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,OAAO,cAAc,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,OAAO,uBAAuB,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC,CAAC;AACvE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY;IAChD,OAAO,oBAAoB,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,IAAI,6BAA6B,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAC/E,OAAO,eAAe,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,UAAU,0BAA0B;IAKxC,IAAI,eAAe,GAAG,KAAK,CAAC;IAE5B,OAAO;QACL,QAAQ,CAAC,IAAY;YACnB,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC7B,eAAe,GAAG,KAAK,CAAC;gBACxB,OAAO,IAAI,CAAC;YACd,CAAC;YACD,OAAO,KAAK,CAAC;QACf,CAAC;QAED,OAAO,CAAC,IAAY;YAClB,IAAI,oBAAoB,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC/B,eAAe,GAAG,IAAI,CAAC;gBACvB,OAAO,IAAI,CAAC;YACd,CAAC;YACD,IAAI,eAAe,IAAI,qBAAqB,CAAC,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC;YAChE,eAAe,GAAG,KAAK,CAAC;YACxB,OAAO,KAAK,CAAC;QACf,CAAC;QAED,SAAS,CAAC,IAAY;YACpB,8DAA8D;YAC9D,qEAAqE;YACrE,IAAI,CAAC,eAAe,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC;gBAAE,OAAO,KAAK,CAAC;YACjE,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC;gBAAE,OAAO,KAAK,CAAC;YAC7C,eAAe,GAAG,KAAK,CAAC;YACxB,OAAO,IAAI,CAAC;QACd,CAAC;KACF,CAAC;AACJ,CAAC;AAED,yGAAyG;AACzG,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC,KAAK,EAAE,MAAM;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAClC,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IAChD,IAAI,IAAI,KAAK,SAAS,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IACrE,OAAO,EAAE,YAAY,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC;AAC9E,CAAC"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python's `LogEnvelope`s. Currently one: **uvicorn** (RT-106).
|
|
3
|
+
*
|
|
4
|
+
* ## This file exists because an earlier claim of mine was too broad
|
|
5
|
+
*
|
|
6
|
+
* RT-103 measured Flask, Django and FastAPI all printing
|
|
7
|
+
* `Traceback (most recent call last):` at column 0 with CPython's own
|
|
8
|
+
* two-space frame indent, because `logging` appends `exc_info` unindented,
|
|
9
|
+
* and concluded **"Python needs no envelope at all and never will."**
|
|
10
|
+
*
|
|
11
|
+
* That is right about *unwrapping* and wrong about *record boundaries*, and
|
|
12
|
+
* the two are different jobs behind one interface. Nothing needs stripping
|
|
13
|
+
* from a Python traceback line. But `UnwrappedLine.startsRecord` is the
|
|
14
|
+
* only mechanism by which a collector can know that a line **ends the
|
|
15
|
+
* previous record**, and Python is the language where that matters most:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* Traceback (most recent call last):
|
|
19
|
+
* INFO: 127.0.0.1:50002 - "GET /health HTTP/1.1" 200 OK
|
|
20
|
+
* File "/app/service.py", line 18, in charge_invoice
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* A traceback is written one `write()` per line, so any other thread
|
|
24
|
+
* logging to the same stream interleaves between them. Measured on real
|
|
25
|
+
* threaded CPython 3.12, three runs of 25 failures: 7, 8 and 7 of them were
|
|
26
|
+
* reported as an EXCEPTION whose entire identity was an access-log line for
|
|
27
|
+
* a **successful** request — see `TRAILER_PATTERN` in
|
|
28
|
+
* `python-frame-parsing.ts` for the full measurement and for the heuristic
|
|
29
|
+
* that an un-enveloped stream falls back on.
|
|
30
|
+
*
|
|
31
|
+
* This envelope makes that case structural rather than guessed. uvicorn
|
|
32
|
+
* *states* where a record begins; the trailer pattern can only infer it.
|
|
33
|
+
*
|
|
34
|
+
* ## Nothing is stripped from a traceback, and that is the whole design
|
|
35
|
+
*
|
|
36
|
+
* uvicorn formats with `"%(levelprefix)s %(message)s"`, where `levelprefix`
|
|
37
|
+
* is `f"{levelname}:".ljust(9)`. That prefix goes on the record's **first**
|
|
38
|
+
* line only. The `exc_info` traceback `logging` appends afterwards is
|
|
39
|
+
* written through untouched, at column 0, with CPython's own indentation.
|
|
40
|
+
*
|
|
41
|
+
* Measured against real `uvicorn 0.41.0` / `fastapi 0.141.1` output
|
|
42
|
+
* (`test/fixtures/uvicorn-asgi-failure.txt`): `ERROR: Exception in ASGI
|
|
43
|
+
* application` is the header, and all 28 traceback lines beneath it are
|
|
44
|
+
* continuations returned byte-identical. So `unwrap` is the identity
|
|
45
|
+
* function for every line that carries evidence, and the envelope's entire
|
|
46
|
+
* contribution is `startsRecord` — the opposite balance to Ruby's, where
|
|
47
|
+
* unwrapping is the load-bearing half.
|
|
48
|
+
*
|
|
49
|
+
* ## Why the level set is closed rather than `\w+`
|
|
50
|
+
*
|
|
51
|
+
* `^(\w+): +` would match `TypeError: ...` — a real trailer, wrongly
|
|
52
|
+
* read as a record boundary, closing a block one line before its identity
|
|
53
|
+
* arrives. That is the same class of over-matching this envelope exists to
|
|
54
|
+
* fix, introduced from the other side. So the set is exactly the six level
|
|
55
|
+
* names `logging` and uvicorn can produce, and a seventh would be a
|
|
56
|
+
* measured addition rather than a widened pattern.
|
|
57
|
+
*
|
|
58
|
+
* ## Two disclosed limits
|
|
59
|
+
*
|
|
60
|
+
* **Colour.** uvicorn wraps `levelprefix` in ANSI when its output is a TTY.
|
|
61
|
+
* Descry pipes, so the measured stream has none, and this pattern would not
|
|
62
|
+
* match a colourised one. Stated rather than handled: adding an optional
|
|
63
|
+
* escape-sequence prefix on an unmeasured shape is the guess this file's
|
|
64
|
+
* own argument rejects.
|
|
65
|
+
*
|
|
66
|
+
* **Application loggers are not covered.** uvicorn configures only its own
|
|
67
|
+
* `uvicorn`/`uvicorn.error`/`uvicorn.access` loggers. An application's own
|
|
68
|
+
* `logging.getLogger(__name__).info(...)` goes through the root config and
|
|
69
|
+
* usually carries no prefix at all, so it comes back from `unwrap`
|
|
70
|
+
* untouched with `startsRecord: false` — correct, because this envelope has
|
|
71
|
+
* no idea whether that line began a record, and saying so would be the
|
|
72
|
+
* inference `fields()` exists to refuse.
|
|
73
|
+
*/
|
|
74
|
+
import type { LogEnvelope } from "@descryy/runtime-backend-observation";
|
|
75
|
+
export declare function createUvicornLogEnvelope(): LogEnvelope;
|
|
76
|
+
//# sourceMappingURL=python-log-envelopes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-log-envelopes.d.ts","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAiC,MAAM,sCAAsC,CAAC;AAqBvG,wBAAgB,wBAAwB,IAAI,WAAW,CAyCtD"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python's `LogEnvelope`s. Currently one: **uvicorn** (RT-106).
|
|
3
|
+
*
|
|
4
|
+
* ## This file exists because an earlier claim of mine was too broad
|
|
5
|
+
*
|
|
6
|
+
* RT-103 measured Flask, Django and FastAPI all printing
|
|
7
|
+
* `Traceback (most recent call last):` at column 0 with CPython's own
|
|
8
|
+
* two-space frame indent, because `logging` appends `exc_info` unindented,
|
|
9
|
+
* and concluded **"Python needs no envelope at all and never will."**
|
|
10
|
+
*
|
|
11
|
+
* That is right about *unwrapping* and wrong about *record boundaries*, and
|
|
12
|
+
* the two are different jobs behind one interface. Nothing needs stripping
|
|
13
|
+
* from a Python traceback line. But `UnwrappedLine.startsRecord` is the
|
|
14
|
+
* only mechanism by which a collector can know that a line **ends the
|
|
15
|
+
* previous record**, and Python is the language where that matters most:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* Traceback (most recent call last):
|
|
19
|
+
* INFO: 127.0.0.1:50002 - "GET /health HTTP/1.1" 200 OK
|
|
20
|
+
* File "/app/service.py", line 18, in charge_invoice
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* A traceback is written one `write()` per line, so any other thread
|
|
24
|
+
* logging to the same stream interleaves between them. Measured on real
|
|
25
|
+
* threaded CPython 3.12, three runs of 25 failures: 7, 8 and 7 of them were
|
|
26
|
+
* reported as an EXCEPTION whose entire identity was an access-log line for
|
|
27
|
+
* a **successful** request — see `TRAILER_PATTERN` in
|
|
28
|
+
* `python-frame-parsing.ts` for the full measurement and for the heuristic
|
|
29
|
+
* that an un-enveloped stream falls back on.
|
|
30
|
+
*
|
|
31
|
+
* This envelope makes that case structural rather than guessed. uvicorn
|
|
32
|
+
* *states* where a record begins; the trailer pattern can only infer it.
|
|
33
|
+
*
|
|
34
|
+
* ## Nothing is stripped from a traceback, and that is the whole design
|
|
35
|
+
*
|
|
36
|
+
* uvicorn formats with `"%(levelprefix)s %(message)s"`, where `levelprefix`
|
|
37
|
+
* is `f"{levelname}:".ljust(9)`. That prefix goes on the record's **first**
|
|
38
|
+
* line only. The `exc_info` traceback `logging` appends afterwards is
|
|
39
|
+
* written through untouched, at column 0, with CPython's own indentation.
|
|
40
|
+
*
|
|
41
|
+
* Measured against real `uvicorn 0.41.0` / `fastapi 0.141.1` output
|
|
42
|
+
* (`test/fixtures/uvicorn-asgi-failure.txt`): `ERROR: Exception in ASGI
|
|
43
|
+
* application` is the header, and all 28 traceback lines beneath it are
|
|
44
|
+
* continuations returned byte-identical. So `unwrap` is the identity
|
|
45
|
+
* function for every line that carries evidence, and the envelope's entire
|
|
46
|
+
* contribution is `startsRecord` — the opposite balance to Ruby's, where
|
|
47
|
+
* unwrapping is the load-bearing half.
|
|
48
|
+
*
|
|
49
|
+
* ## Why the level set is closed rather than `\w+`
|
|
50
|
+
*
|
|
51
|
+
* `^(\w+): +` would match `TypeError: ...` — a real trailer, wrongly
|
|
52
|
+
* read as a record boundary, closing a block one line before its identity
|
|
53
|
+
* arrives. That is the same class of over-matching this envelope exists to
|
|
54
|
+
* fix, introduced from the other side. So the set is exactly the six level
|
|
55
|
+
* names `logging` and uvicorn can produce, and a seventh would be a
|
|
56
|
+
* measured addition rather than a widened pattern.
|
|
57
|
+
*
|
|
58
|
+
* ## Two disclosed limits
|
|
59
|
+
*
|
|
60
|
+
* **Colour.** uvicorn wraps `levelprefix` in ANSI when its output is a TTY.
|
|
61
|
+
* Descry pipes, so the measured stream has none, and this pattern would not
|
|
62
|
+
* match a colourised one. Stated rather than handled: adding an optional
|
|
63
|
+
* escape-sequence prefix on an unmeasured shape is the guess this file's
|
|
64
|
+
* own argument rejects.
|
|
65
|
+
*
|
|
66
|
+
* **Application loggers are not covered.** uvicorn configures only its own
|
|
67
|
+
* `uvicorn`/`uvicorn.error`/`uvicorn.access` loggers. An application's own
|
|
68
|
+
* `logging.getLogger(__name__).info(...)` goes through the root config and
|
|
69
|
+
* usually carries no prefix at all, so it comes back from `unwrap`
|
|
70
|
+
* untouched with `startsRecord: false` — correct, because this envelope has
|
|
71
|
+
* no idea whether that line began a record, and saying so would be the
|
|
72
|
+
* inference `fields()` exists to refuse.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* `INFO: 127.0.0.1:39334 - "GET /health HTTP/1.1" 200 OK`
|
|
76
|
+
*
|
|
77
|
+
* uvicorn's `DefaultFormatter`/`AccessFormatter` both use
|
|
78
|
+
* `"%(levelprefix)s %(message)s"` with `levelprefix = f"{levelname}:".ljust(9)`,
|
|
79
|
+
* so the separator is between 1 and 5 spaces depending on the level's own
|
|
80
|
+
* length — `CRITICAL:` needs no padding, `INFO:` needs four. Anchored at
|
|
81
|
+
* column zero: a traceback's frame lines are indented and its trailer is
|
|
82
|
+
* not level-shaped, so neither can reach this.
|
|
83
|
+
*/
|
|
84
|
+
const UVICORN_HEADER = /^(CRITICAL|ERROR|WARNING|INFO|DEBUG|TRACE): +/;
|
|
85
|
+
/**
|
|
86
|
+
* uvicorn's access line: `127.0.0.1:39334 - "GET /health HTTP/1.1" 200 OK`.
|
|
87
|
+
* Parsed only to populate `attrs` — nothing above the envelope reads it, the
|
|
88
|
+
* same contract `attrs` has in the IR.
|
|
89
|
+
*/
|
|
90
|
+
const ACCESS_MESSAGE = /^(?<client>\S+) - "(?<method>[A-Z]+) (?<path>\S+) (?<protocol>\S+)" (?<status>\d{3})\b/;
|
|
91
|
+
export function createUvicornLogEnvelope() {
|
|
92
|
+
let fields = null;
|
|
93
|
+
return {
|
|
94
|
+
frameworkName: "uvicorn",
|
|
95
|
+
unwrap(line) {
|
|
96
|
+
const match = UVICORN_HEADER.exec(line);
|
|
97
|
+
// Not a header: a traceback line, an application logger's own output,
|
|
98
|
+
// or anything else. Returned byte-identical, and explicitly NOT
|
|
99
|
+
// claiming a boundary — an unrecognised line is not evidence of one.
|
|
100
|
+
if (match === null)
|
|
101
|
+
return { content: line, startsRecord: false };
|
|
102
|
+
const content = line.slice(match[0].length);
|
|
103
|
+
const access = ACCESS_MESSAGE.exec(content);
|
|
104
|
+
fields = {
|
|
105
|
+
// uvicorn's own word, never normalised to a shared vocabulary.
|
|
106
|
+
level: match[1] ?? null,
|
|
107
|
+
// uvicorn's formatter discards the logger name — `uvicorn.access`
|
|
108
|
+
// and `uvicorn.error` are indistinguishable in the printed stream.
|
|
109
|
+
// Null because it is genuinely absent, not merely unparsed.
|
|
110
|
+
category: null,
|
|
111
|
+
// uvicorn has no request-id concept and prints none. Unlike
|
|
112
|
+
// Kestrel's `Request id "..."`, there is nothing here to read, and
|
|
113
|
+
// deriving one from the client port would be an invention.
|
|
114
|
+
requestId: null,
|
|
115
|
+
attrs: access?.groups === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: {
|
|
118
|
+
client: access.groups["client"] ?? "",
|
|
119
|
+
method: access.groups["method"] ?? "",
|
|
120
|
+
path: access.groups["path"] ?? "",
|
|
121
|
+
status: access.groups["status"] ?? "",
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
return { content, startsRecord: true };
|
|
125
|
+
},
|
|
126
|
+
fields: () => fields,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=python-log-envelopes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-log-envelopes.js","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAIH;;;;;;;;;GASG;AACH,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAEvE;;;;GAIG;AACH,MAAM,cAAc,GAAG,wFAAwF,CAAC;AAEhH,MAAM,UAAU,wBAAwB;IACtC,IAAI,MAAM,GAA0B,IAAI,CAAC;IAEzC,OAAO;QACL,aAAa,EAAE,SAAS;QAExB,MAAM,CAAC,IAAY;YACjB,MAAM,KAAK,GAAG,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACxC,sEAAsE;YACtE,gEAAgE;YAChE,qEAAqE;YACrE,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC;YAElE,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAC5C,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC5C,MAAM,GAAG;gBACP,+DAA+D;gBAC/D,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI;gBACvB,kEAAkE;gBAClE,mEAAmE;gBACnE,4DAA4D;gBAC5D,QAAQ,EAAE,IAAI;gBACd,4DAA4D;gBAC5D,mEAAmE;gBACnE,2DAA2D;gBAC3D,SAAS,EAAE,IAAI;gBACf,KAAK,EACH,MAAM,EAAE,MAAM,KAAK,SAAS;oBAC1B,CAAC,CAAC,EAAE;oBACJ,CAAC,CAAC;wBACE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;wBACrC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;wBACrC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE;wBACjC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;qBACtC;aACR,CAAC;YACF,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC;QACzC,CAAC;QAED,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM;KACrB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concrete `RuntimeAdapter` for Python — the second language, and the
|
|
3
|
+
* first one to test whether the seam `@descryy/runtime-backend-observation`
|
|
4
|
+
* defines is genuinely language-neutral or merely V8-shaped.
|
|
5
|
+
*
|
|
6
|
+
* It was not, in two places, and both were fixed in the seam rather than
|
|
7
|
+
* worked around here:
|
|
8
|
+
*
|
|
9
|
+
* - `LineClassifier` had no notion of a line that ends a block and belongs
|
|
10
|
+
* to it, because a V8 header names its exception up front. A CPython
|
|
11
|
+
* traceback names it last. `isTrailer` is that concept, added
|
|
12
|
+
* language-neutrally.
|
|
13
|
+
* - `StackTrace.frames` had no documented order, because with one language
|
|
14
|
+
* nothing ever had to convert. Python prints the opposite of V8. The
|
|
15
|
+
* contract now states most-recent-first and this adapter reverses.
|
|
16
|
+
*
|
|
17
|
+
* Both are recorded because they are the general lesson of adding a second
|
|
18
|
+
* anything: a seam with one implementation is a seam in name only, and the
|
|
19
|
+
* defects it hides are silent ones — a truncated traceback and a
|
|
20
|
+
* backwards frame list both look like well-formed output.
|
|
21
|
+
*/
|
|
22
|
+
import type { RuntimeAdapter } from "@descryy/runtime-backend-observation";
|
|
23
|
+
export interface PythonRuntimeAdapterOptions {
|
|
24
|
+
/** Attributed on every emitted Evidence's `service` field (plan §16.7). Omit when not applicable. */
|
|
25
|
+
readonly service?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The server whose logging layer wraps this process's output (§16).
|
|
28
|
+
*
|
|
29
|
+
* **Declared, never sniffed** — the same rule ASP.NET's option follows,
|
|
30
|
+
* and for a sharper reason here. uvicorn's format is `LEVEL:` plus
|
|
31
|
+
* padding, which is `logging`'s own default shape; detecting it from a
|
|
32
|
+
* line that looks like one would enable record-boundary flushing for any
|
|
33
|
+
* Python process that ever printed `INFO: ...`, and the cost of a wrong
|
|
34
|
+
* boundary is a truncated exception block. A caller that knows it
|
|
35
|
+
* launched uvicorn says so.
|
|
36
|
+
*
|
|
37
|
+
* Only uvicorn, and only because it was measured (RT-106). Gunicorn's
|
|
38
|
+
* default `[%(asctime)s] [%(process)d] [%(levelname)s]` and Django's
|
|
39
|
+
* `runserver` output are different formats and get no envelope until one
|
|
40
|
+
* of them is run and read.
|
|
41
|
+
*/
|
|
42
|
+
readonly server?: "uvicorn";
|
|
43
|
+
}
|
|
44
|
+
export declare function createPythonRuntimeAdapter(options?: PythonRuntimeAdapterOptions): RuntimeAdapter;
|
|
45
|
+
//# sourceMappingURL=python-runtime-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-runtime-adapter.d.ts","sourceRoot":"","sources":["../src/python-runtime-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAuB,MAAM,sCAAsC,CAAC;AAShG,MAAM,WAAW,2BAA2B;IAC1C,qGAAqG;IACrG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B;AAED,wBAAgB,0BAA0B,CAAC,OAAO,GAAE,2BAAgC,GAAG,cAAc,CA4BpG"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concrete `RuntimeAdapter` for Python — the second language, and the
|
|
3
|
+
* first one to test whether the seam `@descryy/runtime-backend-observation`
|
|
4
|
+
* defines is genuinely language-neutral or merely V8-shaped.
|
|
5
|
+
*
|
|
6
|
+
* It was not, in two places, and both were fixed in the seam rather than
|
|
7
|
+
* worked around here:
|
|
8
|
+
*
|
|
9
|
+
* - `LineClassifier` had no notion of a line that ends a block and belongs
|
|
10
|
+
* to it, because a V8 header names its exception up front. A CPython
|
|
11
|
+
* traceback names it last. `isTrailer` is that concept, added
|
|
12
|
+
* language-neutrally.
|
|
13
|
+
* - `StackTrace.frames` had no documented order, because with one language
|
|
14
|
+
* nothing ever had to convert. Python prints the opposite of V8. The
|
|
15
|
+
* contract now states most-recent-first and this adapter reverses.
|
|
16
|
+
*
|
|
17
|
+
* Both are recorded because they are the general lesson of adding a second
|
|
18
|
+
* anything: a seam with one implementation is a seam in name only, and the
|
|
19
|
+
* defects it hides are silent ones — a truncated traceback and a
|
|
20
|
+
* backwards frame list both look like well-formed output.
|
|
21
|
+
*/
|
|
22
|
+
import { createLogCollector } from "@descryy/runtime-backend-observation";
|
|
23
|
+
import { createPythonLineClassifier } from "./python-frame-parsing.js";
|
|
24
|
+
import { createUvicornLogEnvelope } from "./python-log-envelopes.js";
|
|
25
|
+
import { createPythonSourceLocationResolver } from "./python-source-location-resolver.js";
|
|
26
|
+
import { createPythonStackTraceParser } from "./python-stack-trace-parser.js";
|
|
27
|
+
export function createPythonRuntimeAdapter(options = {}) {
|
|
28
|
+
const sourceLocationResolver = createPythonSourceLocationResolver();
|
|
29
|
+
const stackTraceParser = createPythonStackTraceParser(sourceLocationResolver);
|
|
30
|
+
function createBackendCollectors(sources) {
|
|
31
|
+
return sources.map((source) => createLogCollector({
|
|
32
|
+
source,
|
|
33
|
+
// One classifier per collector, never shared: it carries per-stream
|
|
34
|
+
// state, and two processes' output must not prime each other.
|
|
35
|
+
classifier: createPythonLineClassifier(),
|
|
36
|
+
stackTraceParser,
|
|
37
|
+
// One envelope per collector, never shared: it holds the current
|
|
38
|
+
// record's fields, and two processes' output must not attribute
|
|
39
|
+
// each other's. Its whole contribution here is `startsRecord` —
|
|
40
|
+
// nothing is stripped from a Python traceback (RT-106).
|
|
41
|
+
...(options.server === "uvicorn" ? { envelope: createUvicornLogEnvelope() } : {}),
|
|
42
|
+
...(options.service !== undefined ? { service: options.service } : {}),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
language: "python",
|
|
47
|
+
stackTraceParser,
|
|
48
|
+
sourceLocationResolver,
|
|
49
|
+
createBackendCollectors,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=python-runtime-adapter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-runtime-adapter.js","sourceRoot":"","sources":["../src/python-runtime-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,EAAE,kBAAkB,EAAE,MAAM,sCAAsC,CAAC;AAE1E,OAAO,EAAE,0BAA0B,EAAE,MAAM,2BAA2B,CAAC;AACvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AACrE,OAAO,EAAE,kCAAkC,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAwB9E,MAAM,UAAU,0BAA0B,CAAC,UAAuC,EAAE;IAClF,MAAM,sBAAsB,GAAG,kCAAkC,EAAE,CAAC;IACpE,MAAM,gBAAgB,GAAG,4BAA4B,CAAC,sBAAsB,CAAC,CAAC;IAE9E,SAAS,uBAAuB,CAAC,OAAuC;QACtE,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAC5B,kBAAkB,CAAC;YACjB,MAAM;YACN,oEAAoE;YACpE,8DAA8D;YAC9D,UAAU,EAAE,0BAA0B,EAAE;YACxC,gBAAgB;YAChB,iEAAiE;YACjE,gEAAgE;YAChE,gEAAgE;YAChE,wDAAwD;YACxD,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,wBAAwB,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjF,GAAG,CAAC,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvE,CAAC,CACH,CAAC;IACJ,CAAC;IAED,OAAO;QACL,QAAQ,EAAE,QAAQ;QAClB,gBAAgB;QAChB,sBAAsB;QACtB,uBAAuB;KACxB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python `SourceLocationResolver`.
|
|
3
|
+
*
|
|
4
|
+
* **Every location is `self-contained`, and that is a positive claim rather
|
|
5
|
+
* than a fallback.** CPython captures the path and line of the `.py` file it
|
|
6
|
+
* is actually executing; there is no compilation step, no separate map, and
|
|
7
|
+
* therefore nothing that can drift or go missing. The frame IS the answer.
|
|
8
|
+
* Contrast the Node resolver, which must decide between four values because
|
|
9
|
+
* a source map is a second file that can be absent, stale, or wrong.
|
|
10
|
+
*
|
|
11
|
+
* This is the first resolver in the codebase for which the reliability
|
|
12
|
+
* decision is genuinely trivial, and the RT-003 enum already anticipates it:
|
|
13
|
+
* `self-contained` means "nothing was lost", not "we didn't look".
|
|
14
|
+
*
|
|
15
|
+
* **What this deliberately does not do:** check that the file exists on
|
|
16
|
+
* disk, or that its current contents still match what ran. Neither would
|
|
17
|
+
* change the reliability value — `self-contained` is a statement about the
|
|
18
|
+
* *mechanism*, not about whether the working tree has since been edited —
|
|
19
|
+
* and a stat call per frame would buy nothing but latency. CPython's
|
|
20
|
+
* pseudo-files (`<string>`, `<stdin>`, `<frozen importlib._bootstrap>`) are
|
|
21
|
+
* passed through verbatim for the same reason: they are the honest location,
|
|
22
|
+
* and rewriting them to null would discard real information.
|
|
23
|
+
*
|
|
24
|
+
* `column` is always null. CPython does not print columns; 3.11+ prints a
|
|
25
|
+
* caret line under the failing expression, from which a column *could* be
|
|
26
|
+
* inferred by counting characters. That inference is not made here — it
|
|
27
|
+
* would be a derived guess presented in a field callers read as captured
|
|
28
|
+
* fact, and the caret line is preserved verbatim in `StackFrame.raw`
|
|
29
|
+
* regardless.
|
|
30
|
+
*/
|
|
31
|
+
import type { SourceLocationResolver } from "@descryy/runtime-backend-observation";
|
|
32
|
+
export declare function createPythonSourceLocationResolver(): SourceLocationResolver;
|
|
33
|
+
//# sourceMappingURL=python-source-location-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-source-location-resolver.d.ts","sourceRoot":"","sources":["../src/python-source-location-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAoB,sBAAsB,EAAE,MAAM,sCAAsC,CAAC;AAErG,wBAAgB,kCAAkC,IAAI,sBAAsB,CAiB3E"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python `SourceLocationResolver`.
|
|
3
|
+
*
|
|
4
|
+
* **Every location is `self-contained`, and that is a positive claim rather
|
|
5
|
+
* than a fallback.** CPython captures the path and line of the `.py` file it
|
|
6
|
+
* is actually executing; there is no compilation step, no separate map, and
|
|
7
|
+
* therefore nothing that can drift or go missing. The frame IS the answer.
|
|
8
|
+
* Contrast the Node resolver, which must decide between four values because
|
|
9
|
+
* a source map is a second file that can be absent, stale, or wrong.
|
|
10
|
+
*
|
|
11
|
+
* This is the first resolver in the codebase for which the reliability
|
|
12
|
+
* decision is genuinely trivial, and the RT-003 enum already anticipates it:
|
|
13
|
+
* `self-contained` means "nothing was lost", not "we didn't look".
|
|
14
|
+
*
|
|
15
|
+
* **What this deliberately does not do:** check that the file exists on
|
|
16
|
+
* disk, or that its current contents still match what ran. Neither would
|
|
17
|
+
* change the reliability value — `self-contained` is a statement about the
|
|
18
|
+
* *mechanism*, not about whether the working tree has since been edited —
|
|
19
|
+
* and a stat call per frame would buy nothing but latency. CPython's
|
|
20
|
+
* pseudo-files (`<string>`, `<stdin>`, `<frozen importlib._bootstrap>`) are
|
|
21
|
+
* passed through verbatim for the same reason: they are the honest location,
|
|
22
|
+
* and rewriting them to null would discard real information.
|
|
23
|
+
*
|
|
24
|
+
* `column` is always null. CPython does not print columns; 3.11+ prints a
|
|
25
|
+
* caret line under the failing expression, from which a column *could* be
|
|
26
|
+
* inferred by counting characters. That inference is not made here — it
|
|
27
|
+
* would be a derived guess presented in a field callers read as captured
|
|
28
|
+
* fact, and the caret line is preserved verbatim in `StackFrame.raw`
|
|
29
|
+
* regardless.
|
|
30
|
+
*/
|
|
31
|
+
export function createPythonSourceLocationResolver() {
|
|
32
|
+
return {
|
|
33
|
+
// The interface is async because other languages must do I/O to answer
|
|
34
|
+
// (Node reads a source map, a JVM reader would read a LineNumberTable).
|
|
35
|
+
// Python genuinely does not, so this body never awaits — returning a
|
|
36
|
+
// resolved promise rather than inventing work to look symmetrical.
|
|
37
|
+
resolve(frame) {
|
|
38
|
+
return Promise.resolve({
|
|
39
|
+
file: frame.file,
|
|
40
|
+
line: frame.line,
|
|
41
|
+
column: frame.column,
|
|
42
|
+
functionName: frame.functionName,
|
|
43
|
+
reliability: "self-contained",
|
|
44
|
+
resolvedVia: null,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=python-source-location-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-source-location-resolver.js","sourceRoot":"","sources":["../src/python-source-location-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAKH,MAAM,UAAU,kCAAkC;IAChD,OAAO;QACL,uEAAuE;QACvE,wEAAwE;QACxE,qEAAqE;QACrE,mEAAmE;QACnE,OAAO,CAAC,KAAuB;YAC7B,OAAO,OAAO,CAAC,OAAO,CAAC;gBACrB,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,IAAI,EAAE,KAAK,CAAC,IAAI;gBAChB,MAAM,EAAE,KAAK,CAAC,MAAM;gBACpB,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,WAAW,EAAE,gBAAgB;gBAC7B,WAAW,EAAE,IAAI;aAClB,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composes the pure CPython line-recognition functions
|
|
3
|
+
* (`python-frame-parsing.ts`) with a `SourceLocationResolver` to implement
|
|
4
|
+
* the full async `StackTraceParser` contract.
|
|
5
|
+
*
|
|
6
|
+
* **The reversal is the load-bearing line in this file.** CPython prints
|
|
7
|
+
* oldest call first — the banner says so out loud, "most recent call last" —
|
|
8
|
+
* and `StackTrace.frames` is contractually most-recent-first. Without the
|
|
9
|
+
* reversal every consumer that reads `frames[0]` as the failing frame would
|
|
10
|
+
* instead get the process entry point: a wrong answer that looks entirely
|
|
11
|
+
* well-formed, with the right frames, the right count, and no error
|
|
12
|
+
* anywhere. Root-cause traversal would point at `main` for every Python
|
|
13
|
+
* failure ever captured.
|
|
14
|
+
*
|
|
15
|
+
* `fidelity` is always reported `"synchronous"`. A CPython traceback is
|
|
16
|
+
* built by walking the actual frame objects of the executing thread, so
|
|
17
|
+
* every frame shown is a genuine, unbroken link. What such a traceback
|
|
18
|
+
* cannot show is the logical caller across an `await` boundary or a thread
|
|
19
|
+
* hand-off, where the chain truthfully ends — the same hard limit V8 stacks
|
|
20
|
+
* have, and the same reason this is not reported as
|
|
21
|
+
* `"concurrency-fragmented"`. Detecting a genuinely lost logical caller
|
|
22
|
+
* would need interpreter-level task correlation, not text parsing; claiming
|
|
23
|
+
* fragmentation without that evidence would be a guess.
|
|
24
|
+
*/
|
|
25
|
+
import type { SourceLocationResolver, StackTraceParser } from "@descryy/runtime-backend-observation";
|
|
26
|
+
export declare function createPythonStackTraceParser(resolver: SourceLocationResolver): StackTraceParser;
|
|
27
|
+
//# sourceMappingURL=python-stack-trace-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-stack-trace-parser.d.ts","sourceRoot":"","sources":["../src/python-stack-trace-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,KAAK,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAIrG,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,sBAAsB,GAAG,gBAAgB,CAmD/F"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composes the pure CPython line-recognition functions
|
|
3
|
+
* (`python-frame-parsing.ts`) with a `SourceLocationResolver` to implement
|
|
4
|
+
* the full async `StackTraceParser` contract.
|
|
5
|
+
*
|
|
6
|
+
* **The reversal is the load-bearing line in this file.** CPython prints
|
|
7
|
+
* oldest call first — the banner says so out loud, "most recent call last" —
|
|
8
|
+
* and `StackTrace.frames` is contractually most-recent-first. Without the
|
|
9
|
+
* reversal every consumer that reads `frames[0]` as the failing frame would
|
|
10
|
+
* instead get the process entry point: a wrong answer that looks entirely
|
|
11
|
+
* well-formed, with the right frames, the right count, and no error
|
|
12
|
+
* anywhere. Root-cause traversal would point at `main` for every Python
|
|
13
|
+
* failure ever captured.
|
|
14
|
+
*
|
|
15
|
+
* `fidelity` is always reported `"synchronous"`. A CPython traceback is
|
|
16
|
+
* built by walking the actual frame objects of the executing thread, so
|
|
17
|
+
* every frame shown is a genuine, unbroken link. What such a traceback
|
|
18
|
+
* cannot show is the logical caller across an `await` boundary or a thread
|
|
19
|
+
* hand-off, where the chain truthfully ends — the same hard limit V8 stacks
|
|
20
|
+
* have, and the same reason this is not reported as
|
|
21
|
+
* `"concurrency-fragmented"`. Detecting a genuinely lost logical caller
|
|
22
|
+
* would need interpreter-level task correlation, not text parsing; claiming
|
|
23
|
+
* fragmentation without that evidence would be a guess.
|
|
24
|
+
*/
|
|
25
|
+
import { parsePythonFrame } from "./python-frame-parsing.js";
|
|
26
|
+
export function createPythonStackTraceParser(resolver) {
|
|
27
|
+
return {
|
|
28
|
+
async parse(rawText) {
|
|
29
|
+
const lines = rawText.split(/\r\n|\r|\n/);
|
|
30
|
+
const frames = [];
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const raw = parsePythonFrame(line);
|
|
33
|
+
// Continuation lines (source text, 3.11+ caret markers) and the
|
|
34
|
+
// banner and trailer parse to null here. They are real parts of the
|
|
35
|
+
// block and are preserved in the raw text the collector keeps; they
|
|
36
|
+
// are simply not frames and none is fabricated into one.
|
|
37
|
+
if (raw === null)
|
|
38
|
+
continue;
|
|
39
|
+
const location = await resolver.resolve({
|
|
40
|
+
file: raw.file,
|
|
41
|
+
line: raw.line,
|
|
42
|
+
column: null,
|
|
43
|
+
functionName: raw.functionName,
|
|
44
|
+
});
|
|
45
|
+
frames.push({ location, raw: line });
|
|
46
|
+
}
|
|
47
|
+
if (frames.length === 0) {
|
|
48
|
+
// Nothing frame-shaped at all — not recognisable as a CPython
|
|
49
|
+
// traceback, per this interface's contract: null, never an
|
|
50
|
+
// empty-but-structured StackTrace standing in for "couldn't parse".
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// CPython prints oldest-first; the contract is most-recent-first.
|
|
54
|
+
//
|
|
55
|
+
// `primaryFrameIndex` is required so that every adapter states an
|
|
56
|
+
// answer rather than inheriting one (RT-083). Python's is 0 **after
|
|
57
|
+
// the reversal on this line** — the last frame CPython prints is the
|
|
58
|
+
// one that raised, and reversing puts it first.
|
|
59
|
+
//
|
|
60
|
+
// **Corrected from the value's first draft**, which justified index 0
|
|
61
|
+
// with "CPython prints no frames of its own above the application's".
|
|
62
|
+
// That is measurably false: `json.loads("{not json}")` called from a
|
|
63
|
+
// two-deep application chain prints
|
|
64
|
+
// `File "/usr/lib/python3.12/json/decoder.py", line 353, in raw_decode`
|
|
65
|
+
// as the LAST frame, i.e. the innermost — three stdlib frames sit
|
|
66
|
+
// above the application's own. Index 0 is still correct, but for the
|
|
67
|
+
// other reason: it marks the failure site, not the nearest owned
|
|
68
|
+
// code, and the contract says outright that a path-shaped stdlib
|
|
69
|
+
// filter "would break on any application that legitimately fails
|
|
70
|
+
// inside a library frame."
|
|
71
|
+
return { fidelity: "synchronous", frames: frames.reverse(), primaryFrameIndex: 0 };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=python-stack-trace-parser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"python-stack-trace-parser.js","sourceRoot":"","sources":["../src/python-stack-trace-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAE7D,MAAM,UAAU,4BAA4B,CAAC,QAAgC;IAC3E,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,OAAe;YACzB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;YAE1C,MAAM,MAAM,GAAiB,EAAE,CAAC;YAChC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;gBACzB,MAAM,GAAG,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;gBACnC,gEAAgE;gBAChE,oEAAoE;gBACpE,oEAAoE;gBACpE,yDAAyD;gBACzD,IAAI,GAAG,KAAK,IAAI;oBAAE,SAAS;gBAE3B,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC;oBACtC,IAAI,EAAE,GAAG,CAAC,IAAI;oBACd,IAAI,EAAE,GAAG,CAAC,IAAI;oBACd,MAAM,EAAE,IAAI;oBACZ,YAAY,EAAE,GAAG,CAAC,YAAY;iBAC/B,CAAC,CAAC;gBACH,MAAM,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;YACvC,CAAC;YAED,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACxB,8DAA8D;gBAC9D,2DAA2D;gBAC3D,oEAAoE;gBACpE,OAAO,IAAI,CAAC;YACd,CAAC;YAED,kEAAkE;YAClE,EAAE;YACF,kEAAkE;YAClE,oEAAoE;YACpE,qEAAqE;YACrE,gDAAgD;YAChD,EAAE;YACF,sEAAsE;YACtE,sEAAsE;YACtE,qEAAqE;YACrE,oCAAoC;YACpC,wEAAwE;YACxE,kEAAkE;YAClE,qEAAqE;YACrE,iEAAiE;YACjE,iEAAiE;YACjE,iEAAiE;YACjE,2BAA2B;YAC3B,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,EAAE,EAAE,iBAAiB,EAAE,CAAC,EAAE,CAAC;QACrF,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@descryy/runtime-adapter-python",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Python runtime adapter: CPython traceback parsing, self-contained source locations, the concrete RuntimeAdapter for Python.",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.5"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -b"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@descryy/runtime-contracts": "*",
|
|
28
|
+
"@descryy/runtime-backend-observation": "*"
|
|
29
|
+
}
|
|
30
|
+
}
|