@descryy/runtime-adapter-python 0.0.0 → 0.1.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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/outbound-http-instrumentation.d.ts +41 -0
- package/dist/outbound-http-instrumentation.d.ts.map +1 -0
- package/dist/outbound-http-instrumentation.js +47 -0
- package/dist/outbound-http-instrumentation.js.map +1 -0
- package/dist/python-log-envelopes.d.ts +24 -1
- package/dist/python-log-envelopes.d.ts.map +1 -1
- package/dist/python-log-envelopes.js +56 -36
- package/dist/python-log-envelopes.js.map +1 -1
- package/dist/python-runtime-adapter.d.ts +23 -0
- package/dist/python-runtime-adapter.d.ts.map +1 -1
- package/dist/python-runtime-adapter.js +35 -4
- package/dist/python-runtime-adapter.js.map +1 -1
- package/package.json +6 -5
- package/python-preload/__pycache__/descry_outbound_http.cpython-312.pyc +0 -0
- package/python-preload/__pycache__/sitecustomize.cpython-312.pyc +0 -0
- package/python-preload/descry_outbound_http.py +166 -0
- package/python-preload/sitecustomize.py +28 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type { RawPythonFrame } from "./python-frame-parsing.ts";
|
|
2
2
|
export { CHAINED_EXCEPTION_CONNECTIVES, createPythonLineClassifier, isPythonBannerLine, isPythonLocationLine, isPythonTrailerLine, looksLikeContinuation, parsePythonFrame, } from "./python-frame-parsing.ts";
|
|
3
|
-
export { createUvicornLogEnvelope } from "./python-log-envelopes.ts";
|
|
3
|
+
export { createUvicornLogEnvelope, pythonLogPatternRegistry, UVICORN_LOG_PATTERN } from "./python-log-envelopes.ts";
|
|
4
4
|
export { createPythonSourceLocationResolver } from "./python-source-location-resolver.ts";
|
|
5
5
|
export { createPythonStackTraceParser } from "./python-stack-trace-parser.ts";
|
|
6
6
|
export type { PythonRuntimeAdapterOptions } from "./python-runtime-adapter.ts";
|
|
7
7
|
export { createPythonRuntimeAdapter } from "./python-runtime-adapter.ts";
|
|
8
|
+
export { PYTHON_OUTBOUND_HTTP_PRELOAD_DIR, pythonOutboundHttpEnv } from "./outbound-http-instrumentation.ts";
|
|
8
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpH,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;AACzE,OAAO,EAAE,gCAAgC,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { CHAINED_EXCEPTION_CONNECTIVES, createPythonLineClassifier, isPythonBannerLine, isPythonLocationLine, isPythonTrailerLine, looksLikeContinuation, parsePythonFrame, } from "./python-frame-parsing.js";
|
|
2
|
-
export { createUvicornLogEnvelope } from "./python-log-envelopes.js";
|
|
2
|
+
export { createUvicornLogEnvelope, pythonLogPatternRegistry, UVICORN_LOG_PATTERN } from "./python-log-envelopes.js";
|
|
3
3
|
export { createPythonSourceLocationResolver } from "./python-source-location-resolver.js";
|
|
4
4
|
export { createPythonStackTraceParser } from "./python-stack-trace-parser.js";
|
|
5
5
|
export { createPythonRuntimeAdapter } from "./python-runtime-adapter.js";
|
|
6
|
+
export { PYTHON_OUTBOUND_HTTP_PRELOAD_DIR, pythonOutboundHttpEnv } from "./outbound-http-instrumentation.js";
|
|
6
7
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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;
|
|
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,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpH,OAAO,EAAE,kCAAkC,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAE9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AACzE,OAAO,EAAE,gCAAgC,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Python's outbound-HTTP instrumentation lives, and how a caller
|
|
3
|
+
* launches a process with it.
|
|
4
|
+
*
|
|
5
|
+
* The artifact itself is Python (`../python-preload/`), so it cannot live in
|
|
6
|
+
* `src/` and be compiled — it is loaded by CPython, not by tsc. It sits in a
|
|
7
|
+
* sibling directory of `src/` and `dist/` and is resolved by walking out of
|
|
8
|
+
* whichever of the two this module is running from, the identical arrangement
|
|
9
|
+
* `jvm-agent/build.ts` already uses for `Agent.java` and for the identical
|
|
10
|
+
* reason: an `import.meta.url`-relative path that stays *inside* `src` or
|
|
11
|
+
* `dist` resolves to a directory that only exists in one of them.
|
|
12
|
+
*
|
|
13
|
+
* The hop count is one, not `jvm-agent/build.ts`'s two, because that file
|
|
14
|
+
* sits a directory deeper than this one. Getting it wrong produces a path
|
|
15
|
+
* that exists nowhere and a child process that starts perfectly and observes
|
|
16
|
+
* nothing — measured, not hypothetical, and the reason this module's test
|
|
17
|
+
* asserts the directory is really on disk rather than only that the two
|
|
18
|
+
* spellings of the constant agree with each other.
|
|
19
|
+
*
|
|
20
|
+
* **Env, not a rewritten command.** CPython has no `--import`, and rewriting
|
|
21
|
+
* a caller's command string to inject a flag would be this runtime editing
|
|
22
|
+
* how the application boots. `site` imports a module named `sitecustomize` at
|
|
23
|
+
* interpreter startup, so prepending this directory to `PYTHONPATH` runs the
|
|
24
|
+
* instrumentation before the application's own code through a hook CPython
|
|
25
|
+
* already provides. See `sitecustomize.py` for the one real cost of that
|
|
26
|
+
* mechanism, stated there rather than discovered later.
|
|
27
|
+
*/
|
|
28
|
+
/** Absolute path to the directory holding `sitecustomize.py` and the instrumentation it installs. Prepend it to `PYTHONPATH`; never import it from Node. */
|
|
29
|
+
export declare const PYTHON_OUTBOUND_HTTP_PRELOAD_DIR: string;
|
|
30
|
+
/**
|
|
31
|
+
* The environment a Python process must be launched with for its outbound
|
|
32
|
+
* `httpx` calls to be observed.
|
|
33
|
+
*
|
|
34
|
+
* `existingPythonPath` is **prepended to, never replaced**: a target whose own
|
|
35
|
+
* `PYTHONPATH` matters would otherwise stop finding its own modules, which is
|
|
36
|
+
* a way of breaking the application in order to watch it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pythonOutboundHttpEnv(existingPythonPath?: string): {
|
|
39
|
+
readonly PYTHONPATH: string;
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=outbound-http-instrumentation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbound-http-instrumentation.d.ts","sourceRoot":"","sources":["../src/outbound-http-instrumentation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,4JAA4J;AAC5J,eAAO,MAAM,gCAAgC,QAA+D,CAAC;AAE7G;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG;IAAE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAOlG"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Python's outbound-HTTP instrumentation lives, and how a caller
|
|
3
|
+
* launches a process with it.
|
|
4
|
+
*
|
|
5
|
+
* The artifact itself is Python (`../python-preload/`), so it cannot live in
|
|
6
|
+
* `src/` and be compiled — it is loaded by CPython, not by tsc. It sits in a
|
|
7
|
+
* sibling directory of `src/` and `dist/` and is resolved by walking out of
|
|
8
|
+
* whichever of the two this module is running from, the identical arrangement
|
|
9
|
+
* `jvm-agent/build.ts` already uses for `Agent.java` and for the identical
|
|
10
|
+
* reason: an `import.meta.url`-relative path that stays *inside* `src` or
|
|
11
|
+
* `dist` resolves to a directory that only exists in one of them.
|
|
12
|
+
*
|
|
13
|
+
* The hop count is one, not `jvm-agent/build.ts`'s two, because that file
|
|
14
|
+
* sits a directory deeper than this one. Getting it wrong produces a path
|
|
15
|
+
* that exists nowhere and a child process that starts perfectly and observes
|
|
16
|
+
* nothing — measured, not hypothetical, and the reason this module's test
|
|
17
|
+
* asserts the directory is really on disk rather than only that the two
|
|
18
|
+
* spellings of the constant agree with each other.
|
|
19
|
+
*
|
|
20
|
+
* **Env, not a rewritten command.** CPython has no `--import`, and rewriting
|
|
21
|
+
* a caller's command string to inject a flag would be this runtime editing
|
|
22
|
+
* how the application boots. `site` imports a module named `sitecustomize` at
|
|
23
|
+
* interpreter startup, so prepending this directory to `PYTHONPATH` runs the
|
|
24
|
+
* instrumentation before the application's own code through a hook CPython
|
|
25
|
+
* already provides. See `sitecustomize.py` for the one real cost of that
|
|
26
|
+
* mechanism, stated there rather than discovered later.
|
|
27
|
+
*/
|
|
28
|
+
import { delimiter } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
/** Absolute path to the directory holding `sitecustomize.py` and the instrumentation it installs. Prepend it to `PYTHONPATH`; never import it from Node. */
|
|
31
|
+
export const PYTHON_OUTBOUND_HTTP_PRELOAD_DIR = fileURLToPath(new URL("../python-preload", import.meta.url));
|
|
32
|
+
/**
|
|
33
|
+
* The environment a Python process must be launched with for its outbound
|
|
34
|
+
* `httpx` calls to be observed.
|
|
35
|
+
*
|
|
36
|
+
* `existingPythonPath` is **prepended to, never replaced**: a target whose own
|
|
37
|
+
* `PYTHONPATH` matters would otherwise stop finding its own modules, which is
|
|
38
|
+
* a way of breaking the application in order to watch it.
|
|
39
|
+
*/
|
|
40
|
+
export function pythonOutboundHttpEnv(existingPythonPath) {
|
|
41
|
+
return {
|
|
42
|
+
PYTHONPATH: existingPythonPath === undefined || existingPythonPath === ""
|
|
43
|
+
? PYTHON_OUTBOUND_HTTP_PRELOAD_DIR
|
|
44
|
+
: `${PYTHON_OUTBOUND_HTTP_PRELOAD_DIR}${delimiter}${existingPythonPath}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=outbound-http-instrumentation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbound-http-instrumentation.js","sourceRoot":"","sources":["../src/outbound-http-instrumentation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,4JAA4J;AAC5J,MAAM,CAAC,MAAM,gCAAgC,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,mBAAmB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE7G;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CAAC,kBAA2B;IAC/D,OAAO;QACL,UAAU,EACR,kBAAkB,KAAK,SAAS,IAAI,kBAAkB,KAAK,EAAE;YAC3D,CAAC,CAAC,gCAAgC;YAClC,CAAC,CAAC,GAAG,gCAAgC,GAAG,SAAS,GAAG,kBAAkB,EAAE;KAC7E,CAAC;AACJ,CAAC"}
|
|
@@ -71,6 +71,29 @@
|
|
|
71
71
|
* no idea whether that line began a record, and saying so would be the
|
|
72
72
|
* inference `fields()` exists to refuse.
|
|
73
73
|
*/
|
|
74
|
-
import type
|
|
74
|
+
import { type LogEnvelope, type LogPattern, type LogPatternRegistry } from "@descryy/runtime-backend-observation";
|
|
75
|
+
/**
|
|
76
|
+
* uvicorn's wrapper, expressed as a registry entry rather than as a private
|
|
77
|
+
* constant inside one envelope factory.
|
|
78
|
+
*
|
|
79
|
+
* The regexes and the fields are unchanged — this is the same measurement
|
|
80
|
+
* against the same real `uvicorn 0.41.0` output, moved to where a caller can
|
|
81
|
+
* name it and compose it with another format. What changed is that
|
|
82
|
+
* `PythonRuntimeAdapterOptions.server` is no longer the entire Python
|
|
83
|
+
* vocabulary: a process that prints uvicorn's access lines AND formatted
|
|
84
|
+
* console output can now be given both, which is what UAT phase 2's Phase 9
|
|
85
|
+
* asked for and what a closed union of framework names structurally cannot do.
|
|
86
|
+
*/
|
|
87
|
+
export declare const UVICORN_LOG_PATTERN: LogPattern;
|
|
88
|
+
/**
|
|
89
|
+
* The patterns a Python process can be declared to use: the framework-agnostic
|
|
90
|
+
* built-ins (the bracket-tag consoles — `rich`, and anything else that prints
|
|
91
|
+
* `[TAG] message`) plus uvicorn's own.
|
|
92
|
+
*
|
|
93
|
+
* A fresh registry per call, so a caller registering its own pattern cannot
|
|
94
|
+
* change what another caller resolves a name to.
|
|
95
|
+
*/
|
|
96
|
+
export declare function pythonLogPatternRegistry(): LogPatternRegistry;
|
|
97
|
+
/** Unchanged in behaviour and in name: exactly `pythonLogPatternRegistry().envelope(["uvicorn"])`, kept because it is the spelling every existing caller and test uses. */
|
|
75
98
|
export declare function createUvicornLogEnvelope(): LogEnvelope;
|
|
76
99
|
//# sourceMappingURL=python-log-envelopes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"python-log-envelopes.d.ts","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"python-log-envelopes.d.ts","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,sCAAsC,CAAC;AAqB9C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,EAAE,UA4BjC,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,IAAI,kBAAkB,CAI7D;AAED,2KAA2K;AAC3K,wBAAgB,wBAAwB,IAAI,WAAW,CAEtD"}
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
* no idea whether that line began a record, and saying so would be the
|
|
72
72
|
* inference `fields()` exists to refuse.
|
|
73
73
|
*/
|
|
74
|
+
import { createLogPatternRegistry, createPatternLogEnvelope, } from "@descryy/runtime-backend-observation";
|
|
74
75
|
/**
|
|
75
76
|
* `INFO: 127.0.0.1:39334 - "GET /health HTTP/1.1" 200 OK`
|
|
76
77
|
*
|
|
@@ -88,42 +89,61 @@ const UVICORN_HEADER = /^(CRITICAL|ERROR|WARNING|INFO|DEBUG|TRACE): +/;
|
|
|
88
89
|
* same contract `attrs` has in the IR.
|
|
89
90
|
*/
|
|
90
91
|
const ACCESS_MESSAGE = /^(?<client>\S+) - "(?<method>[A-Z]+) (?<path>\S+) (?<protocol>\S+)" (?<status>\d{3})\b/;
|
|
92
|
+
/**
|
|
93
|
+
* uvicorn's wrapper, expressed as a registry entry rather than as a private
|
|
94
|
+
* constant inside one envelope factory.
|
|
95
|
+
*
|
|
96
|
+
* The regexes and the fields are unchanged — this is the same measurement
|
|
97
|
+
* against the same real `uvicorn 0.41.0` output, moved to where a caller can
|
|
98
|
+
* name it and compose it with another format. What changed is that
|
|
99
|
+
* `PythonRuntimeAdapterOptions.server` is no longer the entire Python
|
|
100
|
+
* vocabulary: a process that prints uvicorn's access lines AND formatted
|
|
101
|
+
* console output can now be given both, which is what UAT phase 2's Phase 9
|
|
102
|
+
* asked for and what a closed union of framework names structurally cannot do.
|
|
103
|
+
*/
|
|
104
|
+
export const UVICORN_LOG_PATTERN = {
|
|
105
|
+
name: "uvicorn",
|
|
106
|
+
header: UVICORN_HEADER,
|
|
107
|
+
headerStartsRecord: true,
|
|
108
|
+
fields(match) {
|
|
109
|
+
const access = ACCESS_MESSAGE.exec(match.input.slice(match[0].length));
|
|
110
|
+
return {
|
|
111
|
+
// uvicorn's own word, never normalised to a shared vocabulary.
|
|
112
|
+
level: match[1] ?? null,
|
|
113
|
+
// uvicorn's formatter discards the logger name — `uvicorn.access`
|
|
114
|
+
// and `uvicorn.error` are indistinguishable in the printed stream.
|
|
115
|
+
// Null because it is genuinely absent, not merely unparsed.
|
|
116
|
+
category: null,
|
|
117
|
+
// uvicorn has no request-id concept and prints none. Unlike
|
|
118
|
+
// Kestrel's `Request id "..."`, there is nothing here to read, and
|
|
119
|
+
// deriving one from the client port would be an invention.
|
|
120
|
+
requestId: null,
|
|
121
|
+
attrs: access?.groups === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: {
|
|
124
|
+
client: access.groups["client"] ?? "",
|
|
125
|
+
method: access.groups["method"] ?? "",
|
|
126
|
+
path: access.groups["path"] ?? "",
|
|
127
|
+
status: access.groups["status"] ?? "",
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* The patterns a Python process can be declared to use: the framework-agnostic
|
|
134
|
+
* built-ins (the bracket-tag consoles — `rich`, and anything else that prints
|
|
135
|
+
* `[TAG] message`) plus uvicorn's own.
|
|
136
|
+
*
|
|
137
|
+
* A fresh registry per call, so a caller registering its own pattern cannot
|
|
138
|
+
* change what another caller resolves a name to.
|
|
139
|
+
*/
|
|
140
|
+
export function pythonLogPatternRegistry() {
|
|
141
|
+
const registry = createLogPatternRegistry();
|
|
142
|
+
registry.register(UVICORN_LOG_PATTERN);
|
|
143
|
+
return registry;
|
|
144
|
+
}
|
|
145
|
+
/** Unchanged in behaviour and in name: exactly `pythonLogPatternRegistry().envelope(["uvicorn"])`, kept because it is the spelling every existing caller and test uses. */
|
|
91
146
|
export function createUvicornLogEnvelope() {
|
|
92
|
-
|
|
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
|
-
};
|
|
147
|
+
return createPatternLogEnvelope("uvicorn", [UVICORN_LOG_PATTERN]);
|
|
128
148
|
}
|
|
129
149
|
//# sourceMappingURL=python-log-envelopes.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"python-log-envelopes.js","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;
|
|
1
|
+
{"version":3,"file":"python-log-envelopes.js","sourceRoot":"","sources":["../src/python-log-envelopes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,EACL,wBAAwB,EACxB,wBAAwB,GAKzB,MAAM,sCAAsC,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAM,cAAc,GAAG,+CAA+C,CAAC;AAEvE;;;;GAIG;AACH,MAAM,cAAc,GAAG,wFAAwF,CAAC;AAEhH;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAe;IAC7C,IAAI,EAAE,SAAS;IACf,MAAM,EAAE,cAAc;IACtB,kBAAkB,EAAE,IAAI;IACxB,MAAM,CAAC,KAAsB;QAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;QACvE,OAAO;YACL,+DAA+D;YAC/D,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,IAAI;YACvB,kEAAkE;YAClE,mEAAmE;YACnE,4DAA4D;YAC5D,QAAQ,EAAE,IAAI;YACd,4DAA4D;YAC5D,mEAAmE;YACnE,2DAA2D;YAC3D,SAAS,EAAE,IAAI;YACf,KAAK,EACH,MAAM,EAAE,MAAM,KAAK,SAAS;gBAC1B,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC;oBACE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;oBACrC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;oBACrC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE;oBACjC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;iBACtC;SACR,CAAC;IACJ,CAAC;CACF,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,UAAU,wBAAwB;IACtC,MAAM,QAAQ,GAAG,wBAAwB,EAAE,CAAC;IAC5C,QAAQ,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC;IACvC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,2KAA2K;AAC3K,MAAM,UAAU,wBAAwB;IACtC,OAAO,wBAAwB,CAAC,SAAS,EAAE,CAAC,mBAAmB,CAAC,CAAC,CAAC;AACpE,CAAC"}
|
|
@@ -38,8 +38,31 @@ export interface PythonRuntimeAdapterOptions {
|
|
|
38
38
|
* default `[%(asctime)s] [%(process)d] [%(levelname)s]` and Django's
|
|
39
39
|
* `runserver` output are different formats and get no envelope until one
|
|
40
40
|
* of them is run and read.
|
|
41
|
+
*
|
|
42
|
+
* **Superseded in reach, not in rule, by `logPatterns`.** This option is
|
|
43
|
+
* exactly `logPatterns: ["uvicorn"]` and is kept because it is the spelling
|
|
44
|
+
* every existing caller uses. Declaring both is refused rather than merged.
|
|
41
45
|
*/
|
|
42
46
|
readonly server?: "uvicorn";
|
|
47
|
+
/**
|
|
48
|
+
* Log patterns this process's output is declared to use, by name, from
|
|
49
|
+
* `pythonLogPatternRegistry()` — composed into one envelope in the order
|
|
50
|
+
* given, first match per line winning.
|
|
51
|
+
*
|
|
52
|
+
* **This is UAT phase 2's Phase 9.** `server` was the entire Python
|
|
53
|
+
* vocabulary and it named one framework, so a process printing formatted
|
|
54
|
+
* console output — `[POSTGRES] Creating and opening a Postgres connection
|
|
55
|
+
* pool`, through `rich.console.Console().print()` — had no envelope that
|
|
56
|
+
* could see it, and those were the most operationally interesting lines of
|
|
57
|
+
* the run that found this. `["uvicorn", "bracket-tag-console"]` covers both
|
|
58
|
+
* halves of exactly that process.
|
|
59
|
+
*
|
|
60
|
+
* Still declared, never sniffed, and now for a sharper reason than before:
|
|
61
|
+
* `bracket-tag-console` and Ruby's `rails-tagged-logging` are the same
|
|
62
|
+
* regex with opposite record semantics, so no line can be inspected to
|
|
63
|
+
* decide which applies. An unknown name is refused, not skipped.
|
|
64
|
+
*/
|
|
65
|
+
readonly logPatterns?: readonly string[];
|
|
43
66
|
}
|
|
44
67
|
export declare function createPythonRuntimeAdapter(options?: PythonRuntimeAdapterOptions): RuntimeAdapter;
|
|
45
68
|
//# sourceMappingURL=python-runtime-adapter.d.ts.map
|
|
@@ -1 +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,
|
|
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,EAA2C,MAAM,sCAAsC,CAAC;AAUpH,MAAM,WAAW,2BAA2B;IAC1C,qGAAqG;IACrG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED,wBAAgB,0BAA0B,CAAC,OAAO,GAAE,2BAAgC,GAAG,cAAc,CA6DpG"}
|
|
@@ -21,12 +21,27 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { createLogCollector } from "@descryy/runtime-backend-observation";
|
|
23
23
|
import { createPythonLineClassifier } from "./python-frame-parsing.js";
|
|
24
|
-
import {
|
|
24
|
+
import { pythonLogPatternRegistry } from "./python-log-envelopes.js";
|
|
25
25
|
import { createPythonSourceLocationResolver } from "./python-source-location-resolver.js";
|
|
26
26
|
import { createPythonStackTraceParser } from "./python-stack-trace-parser.js";
|
|
27
|
+
import { PYTHON_OUTBOUND_HTTP_PRELOAD_DIR } from "./outbound-http-instrumentation.js";
|
|
27
28
|
export function createPythonRuntimeAdapter(options = {}) {
|
|
28
29
|
const sourceLocationResolver = createPythonSourceLocationResolver();
|
|
29
30
|
const stackTraceParser = createPythonStackTraceParser(sourceLocationResolver);
|
|
31
|
+
if (options.server !== undefined && options.logPatterns !== undefined) {
|
|
32
|
+
throw new Error("declare either `server` or `logPatterns`, not both -- `server: \"uvicorn\"` IS `logPatterns: [\"uvicorn\"]`, " +
|
|
33
|
+
"and merging them would silently give a caller an envelope with a pattern they did not ask for.");
|
|
34
|
+
}
|
|
35
|
+
const patternNames = options.logPatterns ?? (options.server === undefined ? null : [options.server]);
|
|
36
|
+
const registry = patternNames === null ? null : pythonLogPatternRegistry();
|
|
37
|
+
// Names are checked HERE, not inside `createBackendCollectors`. An unknown
|
|
38
|
+
// pattern name has to fail where the caller declared it: a misspelling that
|
|
39
|
+
// only threw once a process was already spawned, or -- worse -- that
|
|
40
|
+
// resolved to no envelope at all, would be silence exactly where this
|
|
41
|
+
// option exists to remove it. The envelope itself is still built per
|
|
42
|
+
// collector below, because it is stateful.
|
|
43
|
+
if (registry !== null && patternNames !== null)
|
|
44
|
+
registry.envelope(patternNames);
|
|
30
45
|
function createBackendCollectors(sources) {
|
|
31
46
|
return sources.map((source) => createLogCollector({
|
|
32
47
|
source,
|
|
@@ -36,17 +51,33 @@ export function createPythonRuntimeAdapter(options = {}) {
|
|
|
36
51
|
stackTraceParser,
|
|
37
52
|
// One envelope per collector, never shared: it holds the current
|
|
38
53
|
// record's fields, and two processes' output must not attribute
|
|
39
|
-
// each other's.
|
|
40
|
-
// nothing is stripped from a Python traceback (RT-106)
|
|
41
|
-
|
|
54
|
+
// each other's. For uvicorn its whole contribution is `startsRecord`
|
|
55
|
+
// — nothing is stripped from a Python traceback (RT-106); for the
|
|
56
|
+
// console patterns it also strips the tag run.
|
|
57
|
+
...(registry === null || patternNames === null ? {} : { envelope: registry.envelope(patternNames) }),
|
|
42
58
|
...(options.service !== undefined ? { service: options.service } : {}),
|
|
43
59
|
}));
|
|
44
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* CPython has no `--import`. It runs `sitecustomize.py` from anywhere on
|
|
63
|
+
* `PYTHONPATH` at startup, which is the earliest application-independent
|
|
64
|
+
* hook available, so the preload directory goes on `PYTHONPATH` --
|
|
65
|
+
* *prepended*, never replacing: a target whose own `PYTHONPATH` matters
|
|
66
|
+
* would otherwise stop finding its own modules, which is breaking the
|
|
67
|
+
* application in order to watch it.
|
|
68
|
+
*/
|
|
69
|
+
function outboundHttpLaunch() {
|
|
70
|
+
return {
|
|
71
|
+
env: { PYTHONPATH: PYTHON_OUTBOUND_HTTP_PRELOAD_DIR },
|
|
72
|
+
prependToExisting: ["PYTHONPATH"],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
45
75
|
return {
|
|
46
76
|
language: "python",
|
|
47
77
|
stackTraceParser,
|
|
48
78
|
sourceLocationResolver,
|
|
49
79
|
createBackendCollectors,
|
|
80
|
+
outboundHttpLaunch,
|
|
50
81
|
};
|
|
51
82
|
}
|
|
52
83
|
//# sourceMappingURL=python-runtime-adapter.js.map
|
|
@@ -1 +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;
|
|
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;AAC9E,OAAO,EAAE,gCAAgC,EAAE,MAAM,oCAAoC,CAAC;AA+CtF,MAAM,UAAU,0BAA0B,CAAC,UAAuC,EAAE;IAClF,MAAM,sBAAsB,GAAG,kCAAkC,EAAE,CAAC;IACpE,MAAM,gBAAgB,GAAG,4BAA4B,CAAC,sBAAsB,CAAC,CAAC;IAE9E,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,IAAI,OAAO,CAAC,WAAW,KAAK,SAAS,EAAE,CAAC;QACtE,MAAM,IAAI,KAAK,CACb,+GAA+G;YAC7G,gGAAgG,CACnG,CAAC;IACJ,CAAC;IACD,MAAM,YAAY,GAAG,OAAO,CAAC,WAAW,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IACrG,MAAM,QAAQ,GAAG,YAAY,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,wBAAwB,EAAE,CAAC;IAC3E,2EAA2E;IAC3E,4EAA4E;IAC5E,qEAAqE;IACrE,sEAAsE;IACtE,qEAAqE;IACrE,2CAA2C;IAC3C,IAAI,QAAQ,KAAK,IAAI,IAAI,YAAY,KAAK,IAAI;QAAE,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IAEhF,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,qEAAqE;YACrE,kEAAkE;YAClE,+CAA+C;YAC/C,GAAG,CAAC,QAAQ,KAAK,IAAI,IAAI,YAAY,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;YACpG,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;;;;;;;OAOG;IACH,SAAS,kBAAkB;QACzB,OAAO;YACL,GAAG,EAAE,EAAE,UAAU,EAAE,gCAAgC,EAAE;YACrD,iBAAiB,EAAE,CAAC,YAAY,CAAC;SAClC,CAAC;IACJ,CAAC;IAED,OAAO;QACL,QAAQ,EAAE,QAAQ;QAClB,gBAAgB;QAChB,sBAAsB;QACtB,uBAAuB;QACvB,kBAAkB;KACnB,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@descryy/runtime-adapter-python",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Python runtime adapter: CPython traceback parsing, self-contained source locations, the concrete RuntimeAdapter for Python.",
|
|
5
|
+
"description": "Python runtime adapter: CPython traceback parsing, self-contained source locations, outbound httpx call-site instrumentation, the concrete RuntimeAdapter for Python.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=22.5"
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"python-preload"
|
|
18
19
|
],
|
|
19
20
|
"publishConfig": {
|
|
20
21
|
"registry": "https://registry.npmjs.org",
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"build": "tsc -b"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"@descryy/runtime-contracts": "
|
|
28
|
-
"@descryy/runtime-backend-observation": "
|
|
28
|
+
"@descryy/runtime-contracts": "0.1.0",
|
|
29
|
+
"@descryy/runtime-backend-observation": "0.1.0"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Descry's outbound-HTTP instrumentation for CPython — the `httpx` half of
|
|
2
|
+
UAT phase 2 Phase 5's first checkbox, and the counterpart of
|
|
3
|
+
`fetch-instrumentation.ts` for the other runtime.
|
|
4
|
+
|
|
5
|
+
**Why this exists at all.** Runtime evidence could prove a line touched a
|
|
6
|
+
function and never that a call reached a route, because an edge needs a
|
|
7
|
+
caller and an endpoint named by ONE observation, and an HTTP access-log line
|
|
8
|
+
carries no stack. The fix is not to parse harder; it is to capture the stack
|
|
9
|
+
where it still exists, in the calling process, at the call site. That is what
|
|
10
|
+
this file does for `httpx`, exactly as `fetch-instrumentation.ts` does for
|
|
11
|
+
Node's global `fetch`.
|
|
12
|
+
|
|
13
|
+
The UAT workspace is the reason `httpx` is first rather than `requests`: its
|
|
14
|
+
own log says `[HTTP] Shared AsyncClient created`.
|
|
15
|
+
|
|
16
|
+
## Read, never write
|
|
17
|
+
|
|
18
|
+
`send` is called with the request object it was given, untouched. No header
|
|
19
|
+
is added — in particular no trace header is injected, because injecting one
|
|
20
|
+
would change what leaves the process and would also manufacture the very
|
|
21
|
+
correlation the join is supposed to *observe*. Bodies are never read: the
|
|
22
|
+
request's stream is not touched and the response's `status_code` is available
|
|
23
|
+
off the framing without consuming content.
|
|
24
|
+
|
|
25
|
+
## One line of structured JSON, same marker as every other collector
|
|
26
|
+
|
|
27
|
+
`DESCRY_EXTERNAL_REQUEST {json}` on stdout, the exact shape
|
|
28
|
+
`request-marker.ts` declares and `request-line-parser.ts` reads. The protocol
|
|
29
|
+
is language-neutral on purpose: the parser did not need one line changed to
|
|
30
|
+
read these.
|
|
31
|
+
|
|
32
|
+
## The stack is emitted in CPython's own order, deliberately
|
|
33
|
+
|
|
34
|
+
`traceback.format_stack()` yields oldest-call-first, the same order a real
|
|
35
|
+
traceback prints, and `createPythonStackTraceParser` reverses it — its own
|
|
36
|
+
doc calls that reversal "the load-bearing line in this file". Emitting
|
|
37
|
+
already-reversed frames here would be corrected a second time and every
|
|
38
|
+
consumer reading `frames[0]` would get the process entry point: a wrong
|
|
39
|
+
answer with the right frames, the right count and no error anywhere. So this
|
|
40
|
+
prints the banner CPython prints and lets the parser that already exists do
|
|
41
|
+
the work.
|
|
42
|
+
|
|
43
|
+
Frames belonging to this file are dropped **by file, not by count**: the
|
|
44
|
+
primary frame must be the application's call site, and a surviving wrapper
|
|
45
|
+
frame would point every downstream resolution at Descry instead of at the
|
|
46
|
+
code under observation.
|
|
47
|
+
|
|
48
|
+
## What it does not patch, named rather than silently absent
|
|
49
|
+
|
|
50
|
+
`requests`, `urllib3`, `aiohttp`, `urllib.request`. Each is a different
|
|
51
|
+
funnel and none of them has been run and measured here; adding one unmeasured
|
|
52
|
+
is the guess this repository's own log-envelope files already refuse. `httpx`
|
|
53
|
+
is patched at `Client.send` / `AsyncClient.send`, which is the single funnel
|
|
54
|
+
every other httpx entry point (`httpx.get`, `client.post`, ...) goes through.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import sys
|
|
60
|
+
import time
|
|
61
|
+
import traceback
|
|
62
|
+
|
|
63
|
+
_MARKER = "DESCRY_EXTERNAL_REQUEST"
|
|
64
|
+
_SELF = os.path.abspath(__file__)
|
|
65
|
+
_PATCHED = "_descry_outbound_http_patched"
|
|
66
|
+
|
|
67
|
+
# Directories whose frames are dispatch, not a call site. This file's own is
|
|
68
|
+
# obvious. The instrumented library's is the one that had to be measured:
|
|
69
|
+
# `Client.send` is not what an application calls -- `client.get(...)` goes
|
|
70
|
+
# through `Client.get` and `Client.request` first, so the innermost surviving
|
|
71
|
+
# frame was `httpx/_client.py`, and after the parser's reversal every consumer
|
|
72
|
+
# reading `frames[0]` would have been told the caller is httpx. That is the
|
|
73
|
+
# same failure dropping our own wrapper frame prevents, one library out, and
|
|
74
|
+
# it gets the same treatment for the same reason: these frames exist because
|
|
75
|
+
# of the patch point, not because of what the application did.
|
|
76
|
+
_DISPATCH_DIRS = [os.path.dirname(_SELF)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _call_site_stack():
|
|
80
|
+
"""The application's own stack, CPython order, without dispatch frames."""
|
|
81
|
+
frames = [
|
|
82
|
+
entry
|
|
83
|
+
for entry in traceback.format_stack()
|
|
84
|
+
if not any(directory in entry for directory in _DISPATCH_DIRS)
|
|
85
|
+
]
|
|
86
|
+
if not frames:
|
|
87
|
+
return None
|
|
88
|
+
return "Traceback (most recent call last):\n" + "".join(frames)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _emit(url, method, headers, status, duration_ms, success, error, stack):
|
|
92
|
+
payload = {
|
|
93
|
+
"url": url,
|
|
94
|
+
"method": method,
|
|
95
|
+
"requestHeaders": headers,
|
|
96
|
+
"status": status,
|
|
97
|
+
"durationMs": duration_ms,
|
|
98
|
+
"success": success,
|
|
99
|
+
"error": error,
|
|
100
|
+
"stack": stack,
|
|
101
|
+
}
|
|
102
|
+
# `sys.stdout.write` plus an explicit flush, never `print`: a piped stdout
|
|
103
|
+
# is block-buffered, and a marker line that arrives only at process exit is
|
|
104
|
+
# invisible to a live observation window.
|
|
105
|
+
sys.stdout.write("%s %s\n" % (_MARKER, json.dumps(payload)))
|
|
106
|
+
sys.stdout.flush()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _describe(request):
|
|
110
|
+
return str(request.url), request.method, {k.lower(): v for k, v in request.headers.items()}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _install(httpx):
|
|
114
|
+
if getattr(httpx, _PATCHED, False):
|
|
115
|
+
return
|
|
116
|
+
setattr(httpx, _PATCHED, True)
|
|
117
|
+
_DISPATCH_DIRS.append(os.path.dirname(os.path.abspath(httpx.__file__)))
|
|
118
|
+
|
|
119
|
+
original_send = httpx.Client.send
|
|
120
|
+
original_async_send = httpx.AsyncClient.send
|
|
121
|
+
|
|
122
|
+
def send(self, request, **kwargs):
|
|
123
|
+
url, method, headers = _describe(request)
|
|
124
|
+
stack = _call_site_stack()
|
|
125
|
+
started = time.perf_counter()
|
|
126
|
+
try:
|
|
127
|
+
response = original_send(self, request, **kwargs)
|
|
128
|
+
except BaseException as error:
|
|
129
|
+
_emit(url, method, headers, None, (time.perf_counter() - started) * 1000.0,
|
|
130
|
+
False, "%s: %s" % (type(error).__name__, error), stack)
|
|
131
|
+
raise
|
|
132
|
+
_emit(url, method, headers, response.status_code,
|
|
133
|
+
(time.perf_counter() - started) * 1000.0, True, None, stack)
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
async def async_send(self, request, **kwargs):
|
|
137
|
+
url, method, headers = _describe(request)
|
|
138
|
+
stack = _call_site_stack()
|
|
139
|
+
started = time.perf_counter()
|
|
140
|
+
try:
|
|
141
|
+
response = await original_async_send(self, request, **kwargs)
|
|
142
|
+
except BaseException as error:
|
|
143
|
+
_emit(url, method, headers, None, (time.perf_counter() - started) * 1000.0,
|
|
144
|
+
False, "%s: %s" % (type(error).__name__, error), stack)
|
|
145
|
+
raise
|
|
146
|
+
_emit(url, method, headers, response.status_code,
|
|
147
|
+
(time.perf_counter() - started) * 1000.0, True, None, stack)
|
|
148
|
+
return response
|
|
149
|
+
|
|
150
|
+
httpx.Client.send = send
|
|
151
|
+
httpx.AsyncClient.send = async_send
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def install():
|
|
155
|
+
"""Patch httpx if it is importable. Absent httpx is not an error.
|
|
156
|
+
|
|
157
|
+
An application that does not use httpx is not a failure to observe; it is
|
|
158
|
+
an application with no outbound httpx calls, and raising here would break
|
|
159
|
+
a process this module is only supposed to watch.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
import httpx
|
|
163
|
+
except ImportError:
|
|
164
|
+
return False
|
|
165
|
+
_install(httpx)
|
|
166
|
+
return True
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The CPython equivalent of `node --import <preload>`.
|
|
2
|
+
|
|
3
|
+
CPython has no `--import`. `site` imports a module named `sitecustomize` at
|
|
4
|
+
interpreter startup if one is importable, so prepending this directory to
|
|
5
|
+
`PYTHONPATH` runs the instrumentation before the application's own code — the
|
|
6
|
+
same before-the-app-runs guarantee the Node preload gets, through the hook
|
|
7
|
+
CPython actually provides.
|
|
8
|
+
|
|
9
|
+
**The disclosed cost.** `sitecustomize` is a single global name. If the target
|
|
10
|
+
environment already has its own, this directory being first on `PYTHONPATH`
|
|
11
|
+
shadows it, and that environment's own startup customisation silently stops
|
|
12
|
+
running. That is a real risk of this mechanism rather than of this file, it is
|
|
13
|
+
the mechanism every Python APM agent uses for the same reason, and it is
|
|
14
|
+
stated here rather than discovered later. A caller that knows its target ships
|
|
15
|
+
a `sitecustomize` should not use this.
|
|
16
|
+
|
|
17
|
+
Failures here are swallowed on purpose and reported on stderr rather than
|
|
18
|
+
raised: a broken observer must not stop the observed process from starting.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from descry_outbound_http import install
|
|
25
|
+
|
|
26
|
+
install()
|
|
27
|
+
except Exception as error: # noqa: BLE001 - see the module docstring
|
|
28
|
+
sys.stderr.write("descry: outbound HTTP instrumentation not installed: %r\n" % (error,))
|