@nodefony/http 10.0.0-beta.4 → 10.0.0-beta.5
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.js +3 -2
- package/dist/nodefony/config/config.js +2 -1
- package/dist/nodefony/interfaces/IRequestTrace.js +1 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +12 -1
- package/dist/nodefony/service/audit-logger.js +3 -17
- package/dist/nodefony/service/http-kernel.js +80 -7
- package/dist/nodefony/service/pretty-request-logger.js +11 -10
- package/dist/nodefony/src/context/Context.js +56 -13
- package/dist/nodefony/src/context/http/HttpContext.js +21 -0
- package/dist/nodefony/src/context/http2/Response.js +20 -1
- package/dist/nodefony/src/context/requestTrace.js +104 -0
- package/dist/nodefony/src/context/timingMode.js +21 -0
- package/dist/nodefony/src/context/unsentResponse.js +30 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +12 -8
- package/dist/nodefony/src/profiler/FrameProfile.js +22 -0
- package/dist/nodefony/src/profiler/Profiler.js +11 -21
- package/dist/nodefony/src/profiler/aggregate.js +26 -6
- package/dist/types/index.d.ts +2 -0
- package/dist/types/nodefony/config/config.d.ts +4 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +9 -0
- package/dist/types/nodefony/interfaces/IRequestTrace.d.ts +56 -0
- package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +8 -0
- package/dist/types/nodefony/interfaces/IRouting.d.ts +16 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +3 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +3 -11
- package/dist/types/nodefony/service/http-kernel.d.ts +50 -0
- package/dist/types/nodefony/src/context/Context.d.ts +25 -1
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +15 -1
- package/dist/types/nodefony/src/context/http2/Response.d.ts +12 -0
- package/dist/types/nodefony/src/context/requestTrace.d.ts +64 -0
- package/dist/types/nodefony/src/context/timingMode.d.ts +23 -0
- package/dist/types/nodefony/src/context/unsentResponse.d.ts +16 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +1 -1
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +16 -101
- package/dist/types/nodefony/src/profiler/aggregate.d.ts +23 -106
- package/docs/diagnostics-channel.md +303 -0
- package/docs/index.md +6 -1
- package/docs/observabilite.md +94 -16
- package/docs/reverse-proxy.md +18 -16
- package/package.json +3 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import diagnosticsChannel from "node:diagnostics_channel";
|
|
2
|
+
// nodefony/src/context/requestTrace.ts
|
|
3
|
+
/**
|
|
4
|
+
* Nom du `TracingChannel` qui encadre chaque requête HTTP et chaque handshake
|
|
5
|
+
* WebSocket — forme `module.fonction` recommandée par la documentation de
|
|
6
|
+
* `node:diagnostics_channel` ; les sous-canaux sont
|
|
7
|
+
* `tracing:nodefony.http.request:start`, `:end`, `:asyncStart`, `:asyncEnd`,
|
|
8
|
+
* `:error`.
|
|
9
|
+
*/
|
|
10
|
+
const REQUEST_TRACE_CHANNEL = "nodefony.http.request";
|
|
11
|
+
const tracing = diagnosticsChannel.tracingChannel(REQUEST_TRACE_CHANNEL);
|
|
12
|
+
const startChannel = tracing.start;
|
|
13
|
+
const endChannel = tracing.end;
|
|
14
|
+
const asyncStartChannel = tracing.asyncStart;
|
|
15
|
+
const asyncEndChannel = tracing.asyncEnd;
|
|
16
|
+
const errorChannel = tracing.error;
|
|
17
|
+
/**
|
|
18
|
+
* Dit si quelqu'un écoute le canal — LA garde du chemin chaud : ~1,6 ns
|
|
19
|
+
* mesurées, aucune allocation. Lue une seule fois, au début de la requête :
|
|
20
|
+
* un abonné arrivé ensuite ne voit que les requêtes suivantes (règle des
|
|
21
|
+
* `TracingChannel`, qui interdit une fin sans début).
|
|
22
|
+
*
|
|
23
|
+
* @returns `true` si au moins un sous-canal a un abonné
|
|
24
|
+
*/
|
|
25
|
+
function hasRequestTraceSubscribers() {
|
|
26
|
+
return tracing.hasSubscribers;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Crée le message de la requête et le range sur son contexte. À n'appeler
|
|
30
|
+
* que sous {@link hasRequestTraceSubscribers} : c'est la seule allocation
|
|
31
|
+
* du canal, et elle n'a lieu que si quelqu'un écoute.
|
|
32
|
+
*
|
|
33
|
+
* @param context - le contexte qui vient d'être construit
|
|
34
|
+
* @param protocol - requête HTTP ou handshake WebSocket
|
|
35
|
+
* @returns le message, de forme fixe, dont les champs de résultat valent `null`
|
|
36
|
+
*/
|
|
37
|
+
function openRequestTrace(context, protocol) {
|
|
38
|
+
const trace = {
|
|
39
|
+
requestId: context.requestId,
|
|
40
|
+
protocol,
|
|
41
|
+
method: context.method,
|
|
42
|
+
scheme: context.scheme,
|
|
43
|
+
url: context.url,
|
|
44
|
+
route: null,
|
|
45
|
+
routePath: null,
|
|
46
|
+
module: null,
|
|
47
|
+
statusCode: null,
|
|
48
|
+
durationMs: null,
|
|
49
|
+
error: null
|
|
50
|
+
};
|
|
51
|
+
context.requestTrace = trace;
|
|
52
|
+
return trace;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Exécute la partie synchrone du traitement sous `start.runStores` puis
|
|
56
|
+
* publie `end` — la séquence de `TracingChannel.tracePromise`. Les stores
|
|
57
|
+
* liés au canal (`bindStore`, ce que fait un agent APM) restent actifs pour
|
|
58
|
+
* toute la suite asynchrone de la requête.
|
|
59
|
+
*
|
|
60
|
+
* @param trace - le message de la requête
|
|
61
|
+
* @param fn - la fonction qui sert la requête
|
|
62
|
+
* @param thisArg - le receveur de `fn`
|
|
63
|
+
* @param args - les arguments de `fn`
|
|
64
|
+
* @returns ce que rend `fn` — un contexte ou sa promesse
|
|
65
|
+
*/
|
|
66
|
+
function runTraced(trace, fn, thisArg, ...args) {
|
|
67
|
+
try {
|
|
68
|
+
return startChannel.runStores(trace, fn, thisArg, ...args);
|
|
69
|
+
} finally {
|
|
70
|
+
endChannel.publish(trace);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Termine la trace de la requête : remplit les champs de résultat puis
|
|
75
|
+
* publie `error` (s'il y a une exception), `asyncStart` et `asyncEnd`.
|
|
76
|
+
* Idempotent — le premier appel gagne, les suivants ne font rien : un
|
|
77
|
+
* handshake WebSocket a plusieurs sorties, une seule publie la fin.
|
|
78
|
+
*
|
|
79
|
+
* La durée est celle de l'horloge unique du contexte (`stopClock`), la même
|
|
80
|
+
* que celle de la ligne d'accès : le canal n'en mesure aucune.
|
|
81
|
+
*
|
|
82
|
+
* @param context - le contexte de la requête
|
|
83
|
+
* @param statusCode - le statut rendu au client, `null` s'il n'y en a pas eu
|
|
84
|
+
* @param error - l'exception attrapée par le pipeline, `null` ou `undefined` sinon
|
|
85
|
+
*/
|
|
86
|
+
function closeRequestTrace(context, statusCode, error) {
|
|
87
|
+
const trace = context.requestTrace;
|
|
88
|
+
if (trace === null) return;
|
|
89
|
+
context.requestTrace = null;
|
|
90
|
+
const route = context.resolver?.route ?? null;
|
|
91
|
+
trace.route = route?.name ?? null;
|
|
92
|
+
const path = route?.path;
|
|
93
|
+
trace.routePath = path === void 0 ? null : path === "" ? "/" : path;
|
|
94
|
+
trace.module = context.logModule ?? route?.module?.name ?? null;
|
|
95
|
+
trace.statusCode = statusCode;
|
|
96
|
+
trace.durationMs = context.stopClock();
|
|
97
|
+
if (error !== null && error !== void 0) {
|
|
98
|
+
trace.error = error;
|
|
99
|
+
errorChannel.publish(trace);
|
|
100
|
+
}
|
|
101
|
+
asyncStartChannel.publish(trace);
|
|
102
|
+
asyncEndChannel.publish(trace);
|
|
103
|
+
}
|
|
104
|
+
export { REQUEST_TRACE_CHANNEL, closeRequestTrace, hasRequestTraceSubscribers, openRequestTrace, runTraced };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// nodefony/src/context/timingMode.ts
|
|
2
|
+
/**
|
|
3
|
+
* Résout le mode de chronométrage d'une requête depuis `timing.enabled` et
|
|
4
|
+
* l'environnement — la SEULE règle, appelée par le constructeur du `Context`.
|
|
5
|
+
*
|
|
6
|
+
* Défaut (`null`, pas d'avis) : la durée partout, les phases hors production.
|
|
7
|
+
* La durée coûte deux lectures d'horloge, l'équivalent de quelques pourcents
|
|
8
|
+
* de la ligne d'accès qu'elle complète ; les phases coûtent une lecture par
|
|
9
|
+
* phase, un tableau et un index par requête — réservées au développement, ou
|
|
10
|
+
* à un diagnostic à chaud (`true`). `false` coupe tout, durée comprise.
|
|
11
|
+
*
|
|
12
|
+
* @param enabled - valeur de `timing.enabled` (`true`, `false`, `null`)
|
|
13
|
+
* @param environment - environnement normalisé du noyau (`"production"`…)
|
|
14
|
+
* @returns le mode de la requête — une chaîne littérale, rien d'alloué
|
|
15
|
+
*/
|
|
16
|
+
function resolveTimingMode(enabled, environment) {
|
|
17
|
+
if (enabled === false) return "off";
|
|
18
|
+
if (enabled === true) return "phases";
|
|
19
|
+
return environment === "production" ? "clock" : "phases";
|
|
20
|
+
}
|
|
21
|
+
export { resolveTimingMode };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// nodefony/src/context/unsentResponse.ts
|
|
2
|
+
/**
|
|
3
|
+
* Rédige l'avertissement de développement d'une requête restée sans réponse :
|
|
4
|
+
* l'action a rendu une valeur que le framework ne sait pas envoyer, et la
|
|
5
|
+
* requête a attendu son délai d'expiration.
|
|
6
|
+
*
|
|
7
|
+
* Le message nomme l'action fautive, ce qu'elle a rendu, et le geste qui
|
|
8
|
+
* convient à ce cas — `null` est presque toujours une entité absente, qui
|
|
9
|
+
* appelle un 404.
|
|
10
|
+
*
|
|
11
|
+
* @param resolver - le resolver de la requête (route, controller, action,
|
|
12
|
+
* valeur non rendue), `null` si aucune route n'a été résolue.
|
|
13
|
+
* @param url - l'URL de la requête, repli quand aucune route n'est connue.
|
|
14
|
+
* @returns le texte de l'avertissement.
|
|
15
|
+
*/
|
|
16
|
+
function unsentResponseWarning(resolver, url) {
|
|
17
|
+
const controller = resolver?.controller?.name;
|
|
18
|
+
const actionName = resolver?.actionName;
|
|
19
|
+
const action = controller && actionName ? `${controller}.${actionName}` : null;
|
|
20
|
+
const route = resolver?.route?.name ?? null;
|
|
21
|
+
const where = action ? `L'action ${action}${route ? ` (route ${route})` : ""}` : `L'action de ${route ?? url}`;
|
|
22
|
+
const returned = resolver?.unrendered ?? null;
|
|
23
|
+
const consequence = "et n'a rien envoyé : la requête a attendu son délai d'expiration.";
|
|
24
|
+
if (returned === "null") return `${where} a rendu null ${consequence} Pour le framework, null veut dire « l'action répond elle-même ». Entité absente → lever HttpError(…, 404) ; sinon rendre la valeur, ou envoyer la réponse soi-même.`;
|
|
25
|
+
if (returned === "undefined") return `${where} n'a rien rendu ${consequence} Un « return » oublié ? Rendre la valeur (objet, chaîne, nombre, booléen, Buffer : envoyés automatiquement), ou envoyer la réponse soi-même.`;
|
|
26
|
+
if (returned?.startsWith("une instance de")) return `${where} a rendu ${returned}, qui ne s'envoie pas telle quelle, ${consequence} Rendre un objet simple (sa forme JSON), ou envoyer la réponse soi-même.`;
|
|
27
|
+
if (returned !== null) return `${where} a rendu une valeur de type ${returned} ${consequence} Rendre un objet, une chaîne, un nombre, un booléen ou un Buffer, ou envoyer la réponse soi-même.`;
|
|
28
|
+
return `${where} n'a rien envoyé : la requête a attendu son délai d'expiration. Rendre la valeur, ou envoyer la réponse soi-même.`;
|
|
29
|
+
}
|
|
30
|
+
export { unsentResponseWarning };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasForwardingHeaders, resolveForwarded } from "../forwarded.js";
|
|
2
|
+
import { closeRequestTrace } from "../requestTrace.js";
|
|
2
3
|
import Context from "../Context.js";
|
|
3
4
|
import { toWsCloseReason } from "./wsCloseReason.js";
|
|
4
5
|
import WebsocketResponse from "./Response.js";
|
|
@@ -6,7 +7,7 @@ import { sanitizeRequestId } from "../requestId.js";
|
|
|
6
7
|
import { FrameProfile } from "../../profiler/FrameProfile.js";
|
|
7
8
|
import { formatWsLogContent } from "./wsLogContent.js";
|
|
8
9
|
import { parseWebsocketUrl } from "./websocketUrl.js";
|
|
9
|
-
import { RequestContext, logColor } from "nodefony";
|
|
10
|
+
import { RequestContext, frameRequestId, logColor } from "nodefony";
|
|
10
11
|
import { AsyncResource } from "node:async_hooks";
|
|
11
12
|
import url, { URL } from "node:url";
|
|
12
13
|
import Ws from "ws";
|
|
@@ -173,16 +174,16 @@ var WebsocketContext = class extends Context {
|
|
|
173
174
|
this.log(`PROXY WEBSOCKET REQUEST ${fwd.fromStandard ? "Forwarded (RFC 7239)" : "x-forwarded"} VIA : ${this.proxy.proxyVia}`, "DEBUG");
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
|
-
log(pci, severity, msgid, msg) {
|
|
177
|
+
log(pci, severity, msgid, msg, moduleName) {
|
|
177
178
|
if (!msgid) msgid = "WEBSOCKET CONTEXT";
|
|
178
|
-
return super.log(pci, severity, msgid, msg);
|
|
179
|
+
return super.log(pci, severity, msgid, msg, moduleName);
|
|
179
180
|
}
|
|
180
181
|
logRequest(httpError, acceptedProtocol) {
|
|
181
182
|
try {
|
|
182
183
|
const logger = this.httpKernel?.getRequestLogger();
|
|
183
184
|
if (!logger) return;
|
|
184
185
|
const entry = logger.renderWebsocket(this, httpError ?? null, acceptedProtocol ?? null);
|
|
185
|
-
const pdu = this.log(entry.text, entry.severity, entry.msgid);
|
|
186
|
+
const pdu = this.log(entry.text, entry.severity, entry.msgid, void 0, this.logModule ?? this.resolver?.route?.module?.name);
|
|
186
187
|
pdu.requestId ??= this.requestId;
|
|
187
188
|
return pdu;
|
|
188
189
|
} catch {
|
|
@@ -215,6 +216,7 @@ var WebsocketContext = class extends Context {
|
|
|
215
216
|
this.rejected = true;
|
|
216
217
|
this.webSocketState = "error";
|
|
217
218
|
admission.refuse(status, this.response?.handshakeHeaders ?? null);
|
|
219
|
+
if (this.requestTrace !== null) closeRequestTrace(this, status, this.error);
|
|
218
220
|
return true;
|
|
219
221
|
}
|
|
220
222
|
async connect() {
|
|
@@ -228,6 +230,7 @@ var WebsocketContext = class extends Context {
|
|
|
228
230
|
this.requestEnded = true;
|
|
229
231
|
this.connection.on("message", AsyncResource.bind(this.handleMessage.bind(this)));
|
|
230
232
|
this.logRequest(null, this.acceptedProtocol ?? null);
|
|
233
|
+
if (this.requestTrace !== null) closeRequestTrace(this, 101, null);
|
|
231
234
|
this.webSocketState = "connected";
|
|
232
235
|
return this.connection;
|
|
233
236
|
}
|
|
@@ -337,10 +340,11 @@ var WebsocketContext = class extends Context {
|
|
|
337
340
|
* zéro allocation dans ce cas.
|
|
338
341
|
*/
|
|
339
342
|
beginFrame(method, framePath) {
|
|
340
|
-
|
|
343
|
+
const profiled = this.profiling && this.httpKernel?.shouldProfile(framePath) === true;
|
|
344
|
+
if (!profiled && !this.timingEnabled) return null;
|
|
341
345
|
this.#frameSeq += 1;
|
|
342
346
|
return new FrameProfile({
|
|
343
|
-
requestId:
|
|
347
|
+
requestId: frameRequestId(this.requestId, this.#frameSeq),
|
|
344
348
|
type: this.type,
|
|
345
349
|
scheme: this.scheme,
|
|
346
350
|
method,
|
|
@@ -350,7 +354,7 @@ var WebsocketContext = class extends Context {
|
|
|
350
354
|
security: this.security ?? null,
|
|
351
355
|
securityTrace: this.securityTrace,
|
|
352
356
|
timing: this.timingEnabled,
|
|
353
|
-
queries:
|
|
357
|
+
queries: profiled
|
|
354
358
|
});
|
|
355
359
|
}
|
|
356
360
|
/**
|
|
@@ -358,7 +362,7 @@ var WebsocketContext = class extends Context {
|
|
|
358
362
|
* Profiler (no-op hors dev, ou si aucun profil n'a été ouvert).
|
|
359
363
|
*/
|
|
360
364
|
collectFrame(frame) {
|
|
361
|
-
if (frame === null || !
|
|
365
|
+
if (frame === null || !frame.profiled) return;
|
|
362
366
|
this.get("profiler")?.collect(frame);
|
|
363
367
|
}
|
|
364
368
|
/**
|
|
@@ -37,12 +37,22 @@ var FrameProfile = class {
|
|
|
37
37
|
* invocation, il redevient exact, et le SQL se replace dans le waterfall.
|
|
38
38
|
*/
|
|
39
39
|
profilerQueries;
|
|
40
|
+
/**
|
|
41
|
+
* Ce profil sera-t-il gardé par le Profiler ? Faux quand seul le timing
|
|
42
|
+
* l'a ouvert (chemin d'outillage) : son id ne désigne alors aucun profil
|
|
43
|
+
* lisible, et ne doit pas être annoncé au client.
|
|
44
|
+
*/
|
|
45
|
+
get profiled() {
|
|
46
|
+
return this.profilerQueries !== null;
|
|
47
|
+
}
|
|
40
48
|
/** Resolver de la frame — porte route / controller / action. */
|
|
41
49
|
resolver = null;
|
|
42
50
|
/** Statut HTTP-équivalent de l'invocation (200, 403, 404…). */
|
|
43
51
|
response = null;
|
|
44
52
|
error = null;
|
|
45
53
|
timing;
|
|
54
|
+
clockStartMs;
|
|
55
|
+
durationMs = null;
|
|
46
56
|
constructor(init) {
|
|
47
57
|
this.requestId = init.requestId;
|
|
48
58
|
this.type = init.type;
|
|
@@ -54,9 +64,20 @@ var FrameProfile = class {
|
|
|
54
64
|
this.security = init.security;
|
|
55
65
|
this.securityTrace = init.securityTrace;
|
|
56
66
|
this.timing = init.timing;
|
|
67
|
+
this.clockStartMs = init.timing ? performance.now() : 0;
|
|
57
68
|
this.profilerQueries = init.queries ? [] : null;
|
|
58
69
|
}
|
|
59
70
|
/**
|
|
71
|
+
* Arrête l'horloge de la frame (au premier appel) et rend sa durée en ms ;
|
|
72
|
+
* `null` si le timing est éteint. Contrat de `IContext.stopClock`.
|
|
73
|
+
*
|
|
74
|
+
* @returns la durée de l'invocation, ou `null`
|
|
75
|
+
*/
|
|
76
|
+
stopClock() {
|
|
77
|
+
if (!this.timing) return null;
|
|
78
|
+
return this.durationMs ??= performance.now() - this.clockStartMs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
60
81
|
* Ouvre une phase. Contrairement au `Context`, l'index est un **scan arrière**
|
|
61
82
|
* (pas de `Map<nom, index>`) : une frame porte une poignée de phases, et une
|
|
62
83
|
* phase peut être ré-entrante (une action qui en déclenche une autre) — la
|
|
@@ -88,6 +109,7 @@ var FrameProfile = class {
|
|
|
88
109
|
* @param error - l'erreur qui a interrompu la frame, le cas échéant.
|
|
89
110
|
*/
|
|
90
111
|
finish(status, error) {
|
|
112
|
+
this.stopClock();
|
|
91
113
|
this.response = { statusCode: status };
|
|
92
114
|
if (error !== void 0 && error !== null) this.error = { message: thrownMessage(error) };
|
|
93
115
|
}
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { profileLane } from "./aggregate.js";
|
|
2
|
+
import { frameIndexOf } from "nodefony";
|
|
2
3
|
// nodefony/src/profiler/Profiler.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Une phase encore OUVERTE à la collecte (action interrompue par le départ du
|
|
7
|
-
* client : flux SSE, outil MCP annulé) court jusqu'à la collecte — la compter
|
|
8
|
-
* pour zéro publiait 0,3 ms pour un appel coupé à 3 s.
|
|
9
|
-
*/
|
|
10
|
-
function totalDuration(phases) {
|
|
11
|
-
if (phases.length === 0) return null;
|
|
12
|
-
const first = phases.at(0);
|
|
13
|
-
if (first === void 0 || typeof first.startMs !== "number") return null;
|
|
14
|
-
let end = first.startMs;
|
|
15
|
-
let now = null;
|
|
16
|
-
for (const p of phases) {
|
|
17
|
-
const e = p.durationMs === void 0 ? now ??= performance.now() : p.startMs + p.durationMs;
|
|
18
|
-
if (e > end) end = e;
|
|
19
|
-
}
|
|
20
|
-
const d = end - first.startMs;
|
|
21
|
-
return d >= 0 ? Math.round(d * 1e3) / 1e3 : null;
|
|
4
|
+
/** Arrondit une durée à la microseconde (affichage du Suivi). */
|
|
5
|
+
function roundMicro(ms) {
|
|
6
|
+
return ms === null ? null : Math.round(ms * 1e3) / 1e3;
|
|
22
7
|
}
|
|
23
8
|
/** Capacité par défaut du ring buffer (assez pour une session de debug). */
|
|
24
9
|
const DEFAULT_CAP = 500;
|
|
@@ -77,7 +62,7 @@ var Profiler = class {
|
|
|
77
62
|
url: ctx.url ?? "",
|
|
78
63
|
scheme: ctx.scheme ?? "",
|
|
79
64
|
status: ctx.response?.statusCode ?? (ctx.error ? 500 : null),
|
|
80
|
-
durationMs:
|
|
65
|
+
durationMs: roundMicro(ctx.stopClock()),
|
|
81
66
|
route: ctx.resolver?.route?.name ?? null,
|
|
82
67
|
controller: ctx.resolver?.controller?.name ?? null,
|
|
83
68
|
action: ctx.resolver?.actionName ?? null,
|
|
@@ -112,12 +97,17 @@ var Profiler = class {
|
|
|
112
97
|
}
|
|
113
98
|
/**
|
|
114
99
|
* Résumés des requêtes les plus récentes (récent → ancien), capés à `limit`.
|
|
100
|
+
*
|
|
101
|
+
* @param limit - nombre maximal de résumés
|
|
102
|
+
* @param connection - le requestId d'une connexion WebSocket : ses SEULES
|
|
103
|
+
* frames du pont (`<connection>.<n>`, règle `frameIndexOf` du cœur)
|
|
115
104
|
*/
|
|
116
|
-
recent(limit = 60) {
|
|
105
|
+
recent(limit = 60, connection) {
|
|
117
106
|
const out = [];
|
|
118
107
|
const entries = this.#collectAfter(0).reverse();
|
|
119
108
|
for (const e of entries) {
|
|
120
109
|
if (out.length >= limit) break;
|
|
110
|
+
if (connection && frameIndexOf(e.requestId, connection) === null) continue;
|
|
121
111
|
out.push({
|
|
122
112
|
requestId: e.requestId,
|
|
123
113
|
ts: e.ts,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MODULE_PROXY_PREFIXES } from "../proxy/rules.js";
|
|
2
|
+
import { isConsolePath } from "nodefony";
|
|
2
3
|
// nodefony/src/profiler/aggregate.ts
|
|
3
4
|
/**
|
|
4
5
|
* Agrégation des profils de requêtes — règle PURE, sans horloge ni I/O.
|
|
@@ -22,20 +23,28 @@ const ROUTE_SAMPLE_CAP = 256;
|
|
|
22
23
|
const SQL_SHAPE_CAP = 200;
|
|
23
24
|
/** Exemples d'erreurs retenus (statut ≥ 500 ou exception). */
|
|
24
25
|
const ERROR_SAMPLE_CAP = 10;
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Préfixe de l'espace réservé au framework.
|
|
28
|
+
*
|
|
29
|
+
* @deprecated Ne dit pas ce qui est de l'outillage : une connexion
|
|
30
|
+
* d'utilisateur (`/nodefony/security/api/auth/…`) ou une route d'application
|
|
31
|
+
* (`/nodefony/test/index`) passent sous ce préfixe. La règle est
|
|
32
|
+
* `isConsolePath` (`nodefony`), que `profileLane` appelle.
|
|
33
|
+
*/
|
|
26
34
|
const TOOLING_URL_PREFIX = "/nodefony/";
|
|
27
35
|
/**
|
|
28
|
-
* Range un profil par provenance, d'après son URL.
|
|
29
|
-
*
|
|
36
|
+
* Range un profil par provenance, d'après son URL. L'outillage est reconnu par
|
|
37
|
+
* la règle du cœur, `isConsolePath` — la même que les Journaux, la barre de
|
|
38
|
+
* débogage et le Studio : une connexion d'utilisateur reste de l'application.
|
|
39
|
+
* Les anneaux du profileur et le filtre de l'agrégation la lisent tous deux.
|
|
30
40
|
*
|
|
31
41
|
* @param url - URL du profil, absolue ou chemin.
|
|
32
42
|
* @returns la provenance du profil.
|
|
33
43
|
*/
|
|
34
44
|
function profileLane(url) {
|
|
35
45
|
const path = urlPath(url);
|
|
36
|
-
if (path.startsWith("/nodefony/")) return "tooling";
|
|
37
46
|
for (const prefix of MODULE_PROXY_PREFIXES) if (path.startsWith(prefix)) return "asset";
|
|
38
|
-
return "app";
|
|
47
|
+
return isConsolePath(path) ? "tooling" : "app";
|
|
39
48
|
}
|
|
40
49
|
/**
|
|
41
50
|
* Le profil vient-il de l'outillage (routes `/nodefony/…` ou assets relayés)
|
|
@@ -46,6 +55,17 @@ function profileLane(url) {
|
|
|
46
55
|
function isToolingUrl(url) {
|
|
47
56
|
return profileLane(url) !== "app";
|
|
48
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Une requête de cette URL se profile-t-elle ? L'application toujours ;
|
|
60
|
+
* l'outillage et les assets relayés seulement sur demande (`timing.tooling`)
|
|
61
|
+
* — on ne mesure pas la façon dont on mesure.
|
|
62
|
+
*
|
|
63
|
+
* @param url - URL ou chemin de la requête (ou de la frame du pont)
|
|
64
|
+
* @param tooling - profiler aussi l'outillage
|
|
65
|
+
*/
|
|
66
|
+
function isProfiledUrl(url, tooling) {
|
|
67
|
+
return tooling || profileLane(url) === "app";
|
|
68
|
+
}
|
|
49
69
|
/** Groupe des requêtes qu'aucune route n'a servies (404, statique, sondes). */
|
|
50
70
|
const NO_ROUTE = "(aucune route)";
|
|
51
71
|
/** Pseudo-phase : le temps de l'action hors phases imbriquées et hors SQL. */
|
|
@@ -419,4 +439,4 @@ function aggregateProfiles(entries, options = {}) {
|
|
|
419
439
|
for (const entry of entries) aggregator.add(entry);
|
|
420
440
|
return aggregator.result();
|
|
421
441
|
}
|
|
422
|
-
export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isToolingUrl, profileLane, sqlShape, urlPath };
|
|
442
|
+
export { ACTION_SELF_PHASE, ERROR_SAMPLE_CAP, NO_ROUTE, N_PLUS_ONE_MIN_REPEATS, ROUTE_SAMPLE_CAP, SQL_SHAPE_CAP, TOOLING_URL_PREFIX, aggregateProfiles, createProfileAggregator, isFailedProfile, isProfiledUrl, isToolingUrl, profileLane, sqlShape, urlPath };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export { httpConfigSchema } from "./nodefony/config/config.js";
|
|
|
72
72
|
export type { IHttpConfig, IHttpConfigInput, } from "./nodefony/interfaces/IHttpConfig.js";
|
|
73
73
|
export { decideSend, readBackpressureOptions, type WsBackpressurePolicy, type WsSendDecision, type IWsBackpressureOptions, type IBackpressureSocket, type IBackpressureTarget, } from "./nodefony/src/context/websocket/wsBackpressure.js";
|
|
74
74
|
export { isWebsocketContext } from "./nodefony/src/context/websocket/isWebsocketContext.js";
|
|
75
|
+
export { REQUEST_TRACE_CHANNEL } from "./nodefony/src/context/requestTrace.js";
|
|
76
|
+
export type { IRequestTrace, RequestTraceProtocol, } from "./nodefony/interfaces/IRequestTrace.js";
|
|
75
77
|
export { compileDomainPattern, compileDomainPatterns, compileTrustedHosts, isDomainAllowed, } from "./nodefony/src/context/domainMatcher.js";
|
|
76
78
|
export type { DomainPattern, ITrustedHostsConfig, } from "./nodefony/src/context/domainMatcher.js";
|
|
77
79
|
export { MemoryRateLimitStore } from "./nodefony/src/rateLimit/MemoryRateLimitStore.js";
|
|
@@ -198,6 +198,10 @@ export declare const httpConfigSchema: z.ZodObject<{
|
|
|
198
198
|
stripHeaders: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
199
199
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
200
200
|
secure: z.ZodDefault<z.ZodBoolean>;
|
|
201
|
+
accessLog: z.ZodDefault<z.ZodEnum<{
|
|
202
|
+
all: "all";
|
|
203
|
+
errors: "errors";
|
|
204
|
+
}>>;
|
|
201
205
|
}, z.core.$strict>>>;
|
|
202
206
|
}, z.core.$strict>>;
|
|
203
207
|
wsMaxConnectionsPerIp: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -127,6 +127,15 @@ export interface IContext {
|
|
|
127
127
|
readonly phases: PhaseTiming[];
|
|
128
128
|
phaseStart(name: PhaseName): void;
|
|
129
129
|
phaseEnd(name: PhaseName): void;
|
|
130
|
+
/**
|
|
131
|
+
* Arrête l'horloge de la requête (au premier appel) et rend sa durée en ms —
|
|
132
|
+
* la SEULE durée de requête du framework, mesurée partout par défaut ; `null`
|
|
133
|
+
* seulement si `timing.enabled` vaut `false`.
|
|
134
|
+
*
|
|
135
|
+
* Membre REQUIS : le journal d'accès et le profileur le lisent sans repli.
|
|
136
|
+
* Une implémentation maison d'`IContext` qui ne mesure rien rend `null`.
|
|
137
|
+
*/
|
|
138
|
+
stopClock(): number | null;
|
|
130
139
|
profiling: boolean;
|
|
131
140
|
securityTrace: ISecurityTrace | null;
|
|
132
141
|
onAfterResponse(fn: AfterResponseHandler): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SchemeType } from "./IContext.js";
|
|
2
|
+
import type { HTTPMethodType } from "./IRequest.js";
|
|
3
|
+
/**
|
|
4
|
+
* Nature de l'opération tracée : une requête HTTP, ou le handshake d'une
|
|
5
|
+
* connexion WebSocket.
|
|
6
|
+
*/
|
|
7
|
+
export type RequestTraceProtocol = "http" | "websocket";
|
|
8
|
+
/**
|
|
9
|
+
* Message publié sur le `TracingChannel` `nodefony.http.request`, le même
|
|
10
|
+
* objet de `start` à `asyncEnd` pour une requête donnée.
|
|
11
|
+
*
|
|
12
|
+
* Les champs d'identité (`requestId`, `protocol`, `method`, `scheme`, `url`)
|
|
13
|
+
* sont posés dès `start`. Les champs de résultat (`route`, `routePath`,
|
|
14
|
+
* `module`, `statusCode`, `durationMs`, `error`) valent `null` jusqu'à la fin
|
|
15
|
+
* de la requête : un abonné les lit sur `asyncEnd`. Tout est en lecture seule
|
|
16
|
+
* pour l'abonné — c'est Nodefony qui les remplit.
|
|
17
|
+
*/
|
|
18
|
+
export interface IRequestTrace {
|
|
19
|
+
/** Identifiant de corrélation de la requête (`X-Request-Id` entrant, sinon généré). */
|
|
20
|
+
readonly requestId: string;
|
|
21
|
+
/** `"http"` pour une requête, `"websocket"` pour un handshake. */
|
|
22
|
+
readonly protocol: RequestTraceProtocol;
|
|
23
|
+
/** Méthode de la requête (`"WEBSOCKET"` pour un handshake). */
|
|
24
|
+
readonly method: HTTPMethodType | null;
|
|
25
|
+
/** Schéma vu par le client, proxy de confiance compris (`url.scheme` d'OpenTelemetry). */
|
|
26
|
+
readonly scheme: SchemeType;
|
|
27
|
+
/** URL complète de la requête, chaîne de requête comprise — l'abonné en tire `url.path` et `url.query`. */
|
|
28
|
+
readonly url: string;
|
|
29
|
+
/** Nom de la route qui a répondu, `null` sans route (statique, 404). */
|
|
30
|
+
readonly route: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Gabarit de la route, préfixe compris (`/users/{id}`) : faible cardinalité,
|
|
33
|
+
* c'est l'attribut `http.route` d'OpenTelemetry. La racine vaut `/`. `null`
|
|
34
|
+
* sans route.
|
|
35
|
+
*/
|
|
36
|
+
readonly routePath: string | null;
|
|
37
|
+
/** Module qui a signé la réponse — le même que celui de la ligne d'accès. */
|
|
38
|
+
readonly module: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Statut rendu au client : celui de l'amont pour une requête relayée, `101`
|
|
41
|
+
* pour un handshake accepté, le statut du refus sinon. `null` si le client
|
|
42
|
+
* est parti avant toute réponse.
|
|
43
|
+
*/
|
|
44
|
+
readonly statusCode: number | null;
|
|
45
|
+
/**
|
|
46
|
+
* Durée de la requête en millisecondes, construction du contexte → dernier
|
|
47
|
+
* octet remis (fin du handshake en WebSocket). `null` si `timing.enabled`
|
|
48
|
+
* vaut `false`.
|
|
49
|
+
*/
|
|
50
|
+
readonly durationMs: number | null;
|
|
51
|
+
/**
|
|
52
|
+
* Exception attrapée par le pipeline, `null` sinon. Un statut 4xx rendu
|
|
53
|
+
* sans exception n'en porte pas ; c'est à l'abonné de classer un statut.
|
|
54
|
+
*/
|
|
55
|
+
readonly error: unknown;
|
|
56
|
+
}
|
|
@@ -59,6 +59,14 @@ export interface IProxyMountOptions {
|
|
|
59
59
|
* ACCEPTÉ que vers la boucle locale (refus au montage sinon).
|
|
60
60
|
*/
|
|
61
61
|
secure?: boolean | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Ligne d'accès des requêtes relayées : `"all"` (défaut) les écrit toutes,
|
|
64
|
+
* `"errors"` seulement celles en échec (statut ≥ 400). Pour un amont qui sert
|
|
65
|
+
* des centaines de fichiers par page (le relais de Vite) : un fichier bien
|
|
66
|
+
* servi n'apprend rien, et ses lignes chassaient les autres de la mémoire
|
|
67
|
+
* du journal.
|
|
68
|
+
*/
|
|
69
|
+
accessLog?: "all" | "errors" | undefined;
|
|
62
70
|
}
|
|
63
71
|
/** Un montage tel que le proxy le garde : préfixe normalisé + réglages. */
|
|
64
72
|
export interface IProxyMount extends IProxyMountOptions {
|
|
@@ -11,6 +11,15 @@ import type { ContextType } from "../service/http-kernel.js";
|
|
|
11
11
|
export interface IResolvedRoute {
|
|
12
12
|
/** Nom de la route (journalisation, diagnostic). */
|
|
13
13
|
readonly name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Gabarit de la route, préfixe compris (`/users/{id}`) — faible cardinalité,
|
|
16
|
+
* publié comme `routePath` sur le canal `nodefony.http.request`.
|
|
17
|
+
*/
|
|
18
|
+
readonly path?: string | undefined;
|
|
19
|
+
/** Module propriétaire — il signe la ligne d'accès de la requête. */
|
|
20
|
+
readonly module?: {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
} | undefined;
|
|
14
23
|
/** Corps laissé en flux (`@Body({ stream: true })`) : le parse est sauté. */
|
|
15
24
|
readonly bodyStream?: boolean | undefined;
|
|
16
25
|
}
|
|
@@ -35,6 +44,13 @@ export interface IRouteResolver {
|
|
|
35
44
|
} | null | undefined;
|
|
36
45
|
/** Nom de l'action résolue. */
|
|
37
46
|
actionName?: string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Ce que l'action a rendu sans que rien ne soit envoyé (`"null"`,
|
|
49
|
+
* `"undefined"`, `"une instance de Book"`…), `null` sinon. Posé seulement
|
|
50
|
+
* sur la branche « l'action répond elle-même » : lu au démontage pour dire,
|
|
51
|
+
* en développement, POURQUOI une requête a attendu son délai d'expiration.
|
|
52
|
+
*/
|
|
53
|
+
readonly unrendered?: string | null | undefined;
|
|
38
54
|
/**
|
|
39
55
|
* Rejoue le match d'une route sur un contexte (WS : handshake puis frames).
|
|
40
56
|
*
|
|
@@ -7,4 +7,5 @@ export type { IContext, IHttpContext, IWebsocketContext, ServerType, ProtocolNam
|
|
|
7
7
|
export type { IHttpKernel } from "./IHttpKernel.js";
|
|
8
8
|
export type { IResolvedRoute, IRouteResolver, IRequestRouter, } from "./IRouting.js";
|
|
9
9
|
export type { ISecurityZone, IFirewallGate } from "./ISecurity.js";
|
|
10
|
+
export type { IRequestTrace, RequestTraceProtocol } from "./IRequestTrace.js";
|
|
10
11
|
export type { IProxyMount, IProxyMountOptions, ProxyTarget, } from "./IReverseProxy.js";
|
|
@@ -10,6 +10,9 @@ import type { Profiler } from "../src/profiler/Profiler.js";
|
|
|
10
10
|
*
|
|
11
11
|
* Endpoints :
|
|
12
12
|
* - `GET /nodefony/profiler/api/recent` → derniers profils (résumés, récent → ancien)
|
|
13
|
+
* `?connection=<requestId>` : les seules frames de cette socket WebSocket
|
|
14
|
+
* - `GET /nodefony/profiler/api/summary` → les profils de l'anneau agrégés par
|
|
15
|
+
* route (percentiles, phases, formes SQL, N+1, erreurs — `aggregateProfiles`)
|
|
13
16
|
* - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
|
|
14
17
|
* - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
|
|
15
18
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Severity } from "nodefony";
|
|
2
2
|
import type { IRequestLogger, IRequestLogEntry } from "../interfaces/IRequestLogger.js";
|
|
3
|
-
import type { IHttpContext, IWebsocketContext
|
|
3
|
+
import type { IContext, IHttpContext, IWebsocketContext } from "../interfaces/IContext.js";
|
|
4
|
+
declare const statusOf: (context: IContext) => number | null;
|
|
4
5
|
/**
|
|
5
6
|
* Canonical audit log entry — 1 JSON PDU per request (P3.1).
|
|
6
7
|
*
|
|
@@ -52,15 +53,6 @@ export interface AuditErrorEntry {
|
|
|
52
53
|
/** Recursive — capped to depth 5 to avoid pathological cycles. */
|
|
53
54
|
cause?: AuditErrorEntry;
|
|
54
55
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Durée totale de la requête, de la première phase (`startMs`) à maintenant.
|
|
57
|
-
* `null` si le chronométrage est coupé (aucune phase enregistrée). Partagée
|
|
58
|
-
* par le journal d'audit et le journal lisible — une seule définition.
|
|
59
|
-
*
|
|
60
|
-
* @param phases - phases chronométrées du contexte
|
|
61
|
-
* @returns durée en millisecondes, ou `null`
|
|
62
|
-
*/
|
|
63
|
-
declare function computeDurationMs(phases: PhaseTiming[]): number | null;
|
|
64
56
|
/**
|
|
65
57
|
* Severity derived from HTTP status code — RFC 9110 categories.
|
|
66
58
|
* 1xx/2xx/3xx → INFO ; 4xx → WARNING ; 5xx → ERROR.
|
|
@@ -145,4 +137,4 @@ declare class JsonAuditLogger implements IRequestLogger {
|
|
|
145
137
|
private serializeError;
|
|
146
138
|
}
|
|
147
139
|
export default JsonAuditLogger;
|
|
148
|
-
export {
|
|
140
|
+
export { severityFromStatus, statusOf };
|