@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 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
@@ -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;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"}
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;AACrE,OAAO,EAAE,kCAAkC,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EAAE,4BAA4B,EAAE,MAAM,gCAAgC,CAAC;AAE9E,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,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 { LogEnvelope } from "@descryy/runtime-backend-observation";
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,EAAE,WAAW,EAAiC,MAAM,sCAAsC,CAAC;AAqBvG,wBAAgB,wBAAwB,IAAI,WAAW,CAyCtD"}
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
- 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
- };
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;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"}
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,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"}
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 { createUvicornLogEnvelope } from "./python-log-envelopes.js";
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. Its whole contribution here is `startsRecord` —
40
- // nothing is stripped from a Python traceback (RT-106).
41
- ...(options.server === "uvicorn" ? { envelope: createUvicornLogEnvelope() } : {}),
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;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"}
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.0.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
  }
@@ -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,))