@pionne/node 0.2.0 → 0.3.1
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.cjs +199 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.mjs +199 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +75 -9
- package/src/security.ts +57 -0
- package/src/sessions.ts +108 -0
package/dist/index.cjs
CHANGED
|
@@ -35,12 +35,133 @@ __export(index_exports, {
|
|
|
35
35
|
});
|
|
36
36
|
module.exports = __toCommonJS(index_exports);
|
|
37
37
|
var os = __toESM(require("os"));
|
|
38
|
-
var
|
|
38
|
+
var process2 = __toESM(require("process"));
|
|
39
|
+
|
|
40
|
+
// src/security.ts
|
|
41
|
+
var TOKEN_PREFIX = "pio_live_";
|
|
42
|
+
var MIN_TOKEN_LENGTH = TOKEN_PREFIX.length + 16;
|
|
43
|
+
function validateEndpoint(endpoint, isDev) {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(endpoint);
|
|
46
|
+
if (u.protocol === "https:") return true;
|
|
47
|
+
if (u.protocol !== "http:") return false;
|
|
48
|
+
if (!isDev) return false;
|
|
49
|
+
return /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|.*\.local)$/.test(u.hostname);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateToken(token) {
|
|
55
|
+
if (typeof token !== "string") return false;
|
|
56
|
+
if (!token.startsWith(TOKEN_PREFIX)) return false;
|
|
57
|
+
if (token.length < MIN_TOKEN_LENGTH) return false;
|
|
58
|
+
const lower = token.toLowerCase();
|
|
59
|
+
for (const bad of ["xxx", "yyy", "todo", "fixme", "replace", "changeme"]) {
|
|
60
|
+
if (lower.includes(bad)) return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
var RateLimiter = class {
|
|
65
|
+
constructor(capacity, refillPerSecond) {
|
|
66
|
+
this.capacity = capacity;
|
|
67
|
+
this.refillPerSecond = refillPerSecond;
|
|
68
|
+
this.tokens = capacity;
|
|
69
|
+
this.lastRefill = Date.now();
|
|
70
|
+
}
|
|
71
|
+
allow() {
|
|
72
|
+
if (this.refillPerSecond <= 0) return true;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const elapsedMs = now - this.lastRefill;
|
|
75
|
+
if (elapsedMs > 0) {
|
|
76
|
+
const refill = elapsedMs / 1e3 * this.refillPerSecond;
|
|
77
|
+
this.tokens = Math.min(this.capacity, this.tokens + refill);
|
|
78
|
+
this.lastRefill = now;
|
|
79
|
+
}
|
|
80
|
+
if (this.tokens >= 1) {
|
|
81
|
+
this.tokens -= 1;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/sessions.ts
|
|
89
|
+
var crypto = __toESM(require("crypto"));
|
|
90
|
+
var current = null;
|
|
91
|
+
function sessionsUrl(ingestEndpoint) {
|
|
92
|
+
if (ingestEndpoint.endsWith("/ingest")) {
|
|
93
|
+
return ingestEndpoint.slice(0, -"/ingest".length) + "/sessions";
|
|
94
|
+
}
|
|
95
|
+
return ingestEndpoint.replace(/\/+$/, "") + "/sessions";
|
|
96
|
+
}
|
|
97
|
+
function postSession(state, status, durationMs) {
|
|
98
|
+
const url = sessionsUrl(state.ctx.endpoint);
|
|
99
|
+
const body = {
|
|
100
|
+
session_id: state.id,
|
|
101
|
+
status,
|
|
102
|
+
release: state.ctx.release,
|
|
103
|
+
environment: state.ctx.environment,
|
|
104
|
+
app_version: state.ctx.appVersion,
|
|
105
|
+
os_name: state.ctx.osName,
|
|
106
|
+
user_id_anon: state.ctx.userIdAnon,
|
|
107
|
+
duration_ms: durationMs
|
|
108
|
+
};
|
|
109
|
+
for (const k of Object.keys(body)) {
|
|
110
|
+
if (body[k] === void 0) delete body[k];
|
|
111
|
+
}
|
|
112
|
+
fetch(url, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"X-Pionne-Token": state.ctx.token
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(body)
|
|
119
|
+
}).catch(() => void 0);
|
|
120
|
+
}
|
|
121
|
+
function startSession(ctx) {
|
|
122
|
+
current = {
|
|
123
|
+
id: crypto.randomUUID(),
|
|
124
|
+
startedAt: Date.now(),
|
|
125
|
+
status: "ok",
|
|
126
|
+
ctx
|
|
127
|
+
};
|
|
128
|
+
postSession(current, "ok");
|
|
129
|
+
const onShutdown = () => {
|
|
130
|
+
if (!current || current.status !== "ok") return;
|
|
131
|
+
flipSession("exited");
|
|
132
|
+
};
|
|
133
|
+
process.once("beforeExit", onShutdown);
|
|
134
|
+
return current.id;
|
|
135
|
+
}
|
|
136
|
+
function flipSession(status) {
|
|
137
|
+
if (!current) return;
|
|
138
|
+
const rank = { ok: 0, exited: 1, errored: 2, abnormal: 3, crashed: 4 };
|
|
139
|
+
if (rank[status] <= rank[current.status]) return;
|
|
140
|
+
current.status = status;
|
|
141
|
+
postSession(current, status, Date.now() - current.startedAt);
|
|
142
|
+
}
|
|
143
|
+
function endSession(status = "exited") {
|
|
144
|
+
if (!current) return;
|
|
145
|
+
flipSession(status);
|
|
146
|
+
current = null;
|
|
147
|
+
}
|
|
148
|
+
function getCurrentSessionId() {
|
|
149
|
+
return current?.id ?? null;
|
|
150
|
+
}
|
|
151
|
+
function flipFromEvent(level, mechanismType) {
|
|
152
|
+
if (mechanismType === "manual") return;
|
|
153
|
+
if (level === "fatal") flipSession("crashed");
|
|
154
|
+
else if (level === "error") flipSession("errored");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/index.ts
|
|
39
158
|
var DEFAULT_ENDPOINT = "https://pionne.agkgcreations.fr/api/ingest";
|
|
40
159
|
var DEFAULT_MAX_STACK = 50;
|
|
41
160
|
var SDK_NAME = "pionne.node";
|
|
42
161
|
var SDK_VERSION = "0.1.0";
|
|
43
162
|
var config = null;
|
|
163
|
+
var rateLimiter = null;
|
|
164
|
+
var droppedByRateLimit = 0;
|
|
44
165
|
var staticContext = {};
|
|
45
166
|
var onUncaught = null;
|
|
46
167
|
var onRejection = null;
|
|
@@ -58,22 +179,22 @@ function gatherStaticContext() {
|
|
|
58
179
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
59
180
|
runtime: {
|
|
60
181
|
name: "node",
|
|
61
|
-
version:
|
|
62
|
-
v8:
|
|
182
|
+
version: process2.versions.node,
|
|
183
|
+
v8: process2.versions.v8
|
|
63
184
|
},
|
|
64
185
|
os: {
|
|
65
186
|
name: os.type(),
|
|
66
187
|
version: os.release(),
|
|
67
|
-
platform:
|
|
68
|
-
arch:
|
|
188
|
+
platform: process2.platform,
|
|
189
|
+
arch: process2.arch,
|
|
69
190
|
cpu_count: os.cpus().length,
|
|
70
191
|
total_memory: os.totalmem(),
|
|
71
192
|
free_memory: os.freemem()
|
|
72
193
|
},
|
|
73
194
|
app: {
|
|
74
195
|
hostname: os.hostname(),
|
|
75
|
-
pid:
|
|
76
|
-
cwd:
|
|
196
|
+
pid: process2.pid,
|
|
197
|
+
cwd: process2.cwd()
|
|
77
198
|
}
|
|
78
199
|
}
|
|
79
200
|
};
|
|
@@ -106,6 +227,13 @@ function buildEvent(err, level, mechanism, handled, extra) {
|
|
|
106
227
|
return event;
|
|
107
228
|
}
|
|
108
229
|
async function send(event) {
|
|
230
|
+
if (rateLimiter && !rateLimiter.allow()) {
|
|
231
|
+
droppedByRateLimit++;
|
|
232
|
+
if (process2.env.NODE_ENV !== "production" && droppedByRateLimit % 50 === 1) {
|
|
233
|
+
console.warn(`[Pionne] rate-limit reached (${droppedByRateLimit} events dropped). Bump maxEventsPerSecond if intentional.`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
109
237
|
if (!config) return;
|
|
110
238
|
try {
|
|
111
239
|
if (typeof fetch !== "function") return;
|
|
@@ -125,46 +253,72 @@ function installUncaughtHandler() {
|
|
|
125
253
|
const event = buildEvent(err, "fatal", "uncaughtException", false);
|
|
126
254
|
if (event) {
|
|
127
255
|
void send(event);
|
|
256
|
+
flipFromEvent(event.level, event.mechanism?.type ?? "uncaughtException");
|
|
128
257
|
}
|
|
129
258
|
};
|
|
130
|
-
|
|
259
|
+
process2.on("uncaughtException", onUncaught);
|
|
131
260
|
}
|
|
132
261
|
function installRejectionHandler() {
|
|
133
262
|
onRejection = (reason) => {
|
|
134
263
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
135
264
|
const event = buildEvent(err, "error", "unhandledRejection", false);
|
|
136
|
-
if (event)
|
|
265
|
+
if (event) {
|
|
266
|
+
void send(event);
|
|
267
|
+
flipFromEvent(event.level, event.mechanism?.type ?? "unhandledRejection");
|
|
268
|
+
}
|
|
137
269
|
};
|
|
138
|
-
|
|
270
|
+
process2.on("unhandledRejection", onRejection);
|
|
139
271
|
}
|
|
140
272
|
var Pionne = {
|
|
141
273
|
init(options) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
274
|
+
try {
|
|
275
|
+
const isDev = process2.env.NODE_ENV !== "production";
|
|
276
|
+
if (!options?.token || !validateToken(options.token)) {
|
|
277
|
+
if (isDev) {
|
|
278
|
+
console.warn("[Pionne] Missing or invalid token (expected pio_live_<\u226516 chars>, no placeholders).");
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
147
281
|
}
|
|
148
|
-
|
|
282
|
+
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
283
|
+
if (!validateEndpoint(endpoint, isDev)) {
|
|
284
|
+
console.warn("[Pionne] Refusing non-HTTPS endpoint in production:", endpoint);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const rps = options.maxEventsPerSecond ?? 10;
|
|
288
|
+
rateLimiter = rps > 0 ? new RateLimiter(rps, rps) : null;
|
|
289
|
+
const autoContext = options.autoContext ?? true;
|
|
290
|
+
staticContext = autoContext ? gatherStaticContext() : {};
|
|
291
|
+
config = {
|
|
292
|
+
token: options.token,
|
|
293
|
+
endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
|
|
294
|
+
release: options.release,
|
|
295
|
+
environment: options.environment ?? process2.env.NODE_ENV ?? "production",
|
|
296
|
+
enabled: options.enabled ?? true,
|
|
297
|
+
captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
|
|
298
|
+
captureUnhandledRejections: options.captureUnhandledRejections ?? true,
|
|
299
|
+
autoContext,
|
|
300
|
+
beforeSend: options.beforeSend,
|
|
301
|
+
userIdAnon: options.userIdAnon,
|
|
302
|
+
tags: options.tags,
|
|
303
|
+
maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
|
|
304
|
+
};
|
|
305
|
+
if (config.captureUncaughtExceptions) installUncaughtHandler();
|
|
306
|
+
if (config.captureUnhandledRejections) installRejectionHandler();
|
|
307
|
+
if (options.releaseHealth !== false) {
|
|
308
|
+
startSession({
|
|
309
|
+
endpoint: config.endpoint,
|
|
310
|
+
token: config.token,
|
|
311
|
+
release: config.release,
|
|
312
|
+
environment: config.environment,
|
|
313
|
+
appVersion: staticContext.app_version,
|
|
314
|
+
osName: staticContext.os_name,
|
|
315
|
+
userIdAnon: config.userIdAnon
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.warn("[Pionne] init failed silently \u2014 monitoring disabled.", e);
|
|
320
|
+
config = null;
|
|
149
321
|
}
|
|
150
|
-
const autoContext = options.autoContext ?? true;
|
|
151
|
-
staticContext = autoContext ? gatherStaticContext() : {};
|
|
152
|
-
config = {
|
|
153
|
-
token: options.token,
|
|
154
|
-
endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
|
|
155
|
-
release: options.release,
|
|
156
|
-
environment: options.environment ?? process.env.NODE_ENV ?? "production",
|
|
157
|
-
enabled: options.enabled ?? true,
|
|
158
|
-
captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
|
|
159
|
-
captureUnhandledRejections: options.captureUnhandledRejections ?? true,
|
|
160
|
-
autoContext,
|
|
161
|
-
beforeSend: options.beforeSend,
|
|
162
|
-
userIdAnon: options.userIdAnon,
|
|
163
|
-
tags: options.tags,
|
|
164
|
-
maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
|
|
165
|
-
};
|
|
166
|
-
if (config.captureUncaughtExceptions) installUncaughtHandler();
|
|
167
|
-
if (config.captureUnhandledRejections) installRejectionHandler();
|
|
168
322
|
},
|
|
169
323
|
captureException(err, extra) {
|
|
170
324
|
const event = buildEvent(
|
|
@@ -203,12 +357,21 @@ var Pionne = {
|
|
|
203
357
|
* clean shutdown. Re-init by calling `init()` again.
|
|
204
358
|
*/
|
|
205
359
|
uninstall() {
|
|
206
|
-
if (onUncaught)
|
|
207
|
-
if (onRejection)
|
|
360
|
+
if (onUncaught) process2.removeListener("uncaughtException", onUncaught);
|
|
361
|
+
if (onRejection) process2.removeListener("unhandledRejection", onRejection);
|
|
208
362
|
onUncaught = null;
|
|
209
363
|
onRejection = null;
|
|
210
364
|
config = null;
|
|
211
365
|
staticContext = {};
|
|
366
|
+
},
|
|
367
|
+
// ─── Release Health ───────────────────────────────────────────────────
|
|
368
|
+
/** Manually end the current session (status='exited'). */
|
|
369
|
+
endSession() {
|
|
370
|
+
endSession();
|
|
371
|
+
},
|
|
372
|
+
/** UUID of the current open session (for diagnostics). */
|
|
373
|
+
getSessionId() {
|
|
374
|
+
return getCurrentSessionId();
|
|
212
375
|
}
|
|
213
376
|
};
|
|
214
377
|
function expressErrorHandler(err, _req, _res, next) {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\nexport type Level = 'fatal' | 'error' | 'warning' | 'info';\nexport type MechanismType =\n | 'uncaughtException'\n | 'unhandledRejection'\n | 'manual';\n\nexport interface Mechanism {\n type: MechanismType;\n handled: boolean;\n}\n\nexport interface PionneEvent {\n exception_type: string;\n message?: string | null;\n stack?: string[];\n level?: Level;\n\n release?: string;\n environment?: string;\n app_version?: string;\n os_name?: string;\n os_version?: string;\n user_id_anon?: string;\n locale?: string;\n timezone?: string;\n\n contexts?: Record<string, Record<string, unknown> | undefined>;\n mechanism?: Mechanism;\n tags?: Record<string, string>;\n}\n\nexport interface PionneOptions {\n /** Project token (starts with `pio_live_`). Required. */\n token: string;\n endpoint?: string;\n release?: string;\n environment?: string;\n enabled?: boolean;\n captureUncaughtExceptions?: boolean;\n captureUnhandledRejections?: boolean;\n autoContext?: boolean;\n beforeSend?: (event: PionneEvent) => PionneEvent | null;\n userIdAnon?: string;\n tags?: Record<string, string>;\n maxStackFrames?: number;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_MAX_STACK = 50;\nconst SDK_NAME = 'pionne.node';\nconst SDK_VERSION = '0.1.0';\n\ntype ResolvedConfig = Required<\n Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release'>\n> & {\n beforeSend?: PionneOptions['beforeSend'];\n userIdAnon?: string;\n tags?: Record<string, string>;\n release?: string;\n};\n\nlet config: ResolvedConfig | null = null;\nlet staticContext: Partial<PionneEvent> = {};\nlet onUncaught: ((err: Error) => void) | null = null;\nlet onRejection: ((reason: unknown) => void) | null = null;\n\nfunction gatherStaticContext(): Partial<PionneEvent> {\n let timezone: string | undefined;\n try {\n timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n } catch {\n // ignore\n }\n return {\n os_name: os.type(),\n os_version: os.release(),\n timezone,\n contexts: {\n sdk: { name: SDK_NAME, version: SDK_VERSION },\n runtime: {\n name: 'node',\n version: process.versions.node,\n v8: process.versions.v8,\n },\n os: {\n name: os.type(),\n version: os.release(),\n platform: process.platform,\n arch: process.arch,\n cpu_count: os.cpus().length,\n total_memory: os.totalmem(),\n free_memory: os.freemem(),\n },\n app: {\n hostname: os.hostname(),\n pid: process.pid,\n cwd: process.cwd(),\n },\n },\n };\n}\n\nfunction parseStack(error: Error, max: number): string[] {\n if (!error.stack) return [];\n return error.stack\n .split('\\n')\n .slice(0, max)\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nfunction buildEvent(\n err: unknown,\n level: Level,\n mechanism: MechanismType,\n handled: boolean,\n extra?: Partial<PionneEvent>,\n): PionneEvent | null {\n if (!config || !config.enabled) return null;\n const e = err instanceof Error ? err : new Error(String(err));\n const event: PionneEvent = {\n ...staticContext,\n exception_type: e.name || 'Error',\n message: e.message || null,\n stack: parseStack(e, config.maxStackFrames),\n level,\n release: config.release,\n environment: config.environment,\n user_id_anon: config.userIdAnon,\n tags: config.tags,\n mechanism: { type: mechanism, handled },\n ...extra,\n };\n if (config.beforeSend) {\n const result = config.beforeSend(event);\n if (!result) return null;\n return result;\n }\n return event;\n}\n\nasync function send(event: PionneEvent): Promise<void> {\n if (!config) return;\n try {\n // Node >=18 has global `fetch`. We rely on it instead of pulling in\n // node-fetch / undici as a dependency.\n if (typeof fetch !== 'function') return;\n await fetch(config.endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': config.token,\n },\n body: JSON.stringify(event),\n });\n } catch {\n // Best-effort: a monitoring SDK must never crash the host process.\n }\n}\n\nfunction installUncaughtHandler(): void {\n onUncaught = (err: Error) => {\n const event = buildEvent(err, 'fatal', 'uncaughtException', false);\n if (event) {\n // Fire-and-forget: process is going to die anyway. Best we can do is\n // try to flush before exit, but Node will tear down imminently.\n void send(event);\n }\n };\n process.on('uncaughtException', onUncaught);\n}\n\nfunction installRejectionHandler(): void {\n onRejection = (reason: unknown) => {\n const err = reason instanceof Error ? reason : new Error(String(reason));\n const event = buildEvent(err, 'error', 'unhandledRejection', false);\n if (event) void send(event);\n };\n process.on('unhandledRejection', onRejection);\n}\n\nexport const Pionne = {\n init(options: PionneOptions): void {\n if (!options?.token || !options.token.startsWith('pio_live_')) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[Pionne] Missing or invalid token (must start with pio_live_).',\n );\n }\n return;\n }\n\n const autoContext = options.autoContext ?? true;\n staticContext = autoContext ? gatherStaticContext() : {};\n\n config = {\n token: options.token,\n endpoint: options.endpoint ?? DEFAULT_ENDPOINT,\n release: options.release,\n environment:\n options.environment ?? process.env.NODE_ENV ?? 'production',\n enabled: options.enabled ?? true,\n captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,\n captureUnhandledRejections: options.captureUnhandledRejections ?? true,\n autoContext,\n beforeSend: options.beforeSend,\n userIdAnon: options.userIdAnon,\n tags: options.tags,\n maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n },\n\n captureException(err: unknown, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n err,\n extra?.level ?? 'error',\n 'manual',\n true,\n extra,\n );\n if (event) void send(event);\n },\n\n captureMessage(message: string, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n new Error(message),\n extra?.level ?? 'info',\n 'manual',\n true,\n { exception_type: 'Message', ...extra },\n );\n if (event) void send(event);\n },\n\n setUser(userIdAnon: string | null): void {\n if (!config) return;\n config.userIdAnon = userIdAnon ?? undefined;\n },\n\n setTags(tags: Record<string, string> | null): void {\n if (!config) return;\n config.tags = tags ?? undefined;\n },\n\n setEnabled(enabled: boolean): void {\n if (!config) return;\n config.enabled = enabled;\n },\n\n /**\n * Detach all auto handlers. Useful in tests / CLI scripts that need a\n * clean shutdown. Re-init by calling `init()` again.\n */\n uninstall(): void {\n if (onUncaught) process.removeListener('uncaughtException', onUncaught);\n if (onRejection) process.removeListener('unhandledRejection', onRejection);\n onUncaught = null;\n onRejection = null;\n config = null;\n staticContext = {};\n },\n};\n\n/**\n * Express / Connect / NestJS error middleware. Reports the error then passes\n * it down the chain. Mount it AFTER your routes:\n *\n * import { Pionne, expressErrorHandler } from '@pionne/node';\n * app.use(expressErrorHandler);\n * // your fallback error handler here\n */\nexport function expressErrorHandler(\n err: unknown,\n _req: unknown,\n _res: unknown,\n next: (err?: unknown) => void,\n): void {\n Pionne.captureException(err);\n next(err);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/security.ts","../src/sessions.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\nimport { RateLimiter, validateEndpoint, validateToken } from './security';\nimport {\n endSession as _endSession,\n flipFromEvent,\n getCurrentSessionId,\n startSession as _startSession,\n} from './sessions';\n\nexport type Level = 'fatal' | 'error' | 'warning' | 'info';\nexport type MechanismType =\n | 'uncaughtException'\n | 'unhandledRejection'\n | 'manual';\n\nexport interface Mechanism {\n type: MechanismType;\n handled: boolean;\n}\n\nexport interface PionneEvent {\n exception_type: string;\n message?: string | null;\n stack?: string[];\n level?: Level;\n\n release?: string;\n environment?: string;\n app_version?: string;\n os_name?: string;\n os_version?: string;\n user_id_anon?: string;\n locale?: string;\n timezone?: string;\n\n contexts?: Record<string, Record<string, unknown> | undefined>;\n mechanism?: Mechanism;\n tags?: Record<string, string>;\n}\n\nexport interface PionneOptions {\n /** Project token (starts with `pio_live_`). Required. */\n token: string;\n endpoint?: string;\n release?: string;\n environment?: string;\n enabled?: boolean;\n captureUncaughtExceptions?: boolean;\n captureUnhandledRejections?: boolean;\n autoContext?: boolean;\n beforeSend?: (event: PionneEvent) => PionneEvent | null;\n userIdAnon?: string;\n tags?: Record<string, string>;\n maxStackFrames?: number;\n /**\n * Release Health — opens a session at init() with status='ok', flips to\n * 'crashed'/'errored' if a fatal/error fires through the global handlers.\n * The dashboard derives crash-free user rate per release. Default: true.\n */\n releaseHealth?: boolean;\n /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */\n maxEventsPerSecond?: number;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_MAX_STACK = 50;\nconst SDK_NAME = 'pionne.node';\nconst SDK_VERSION = '0.1.0';\n\ntype ResolvedConfig = Required<\n Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release' | 'releaseHealth' | 'maxEventsPerSecond'>\n> & {\n beforeSend?: PionneOptions['beforeSend'];\n userIdAnon?: string;\n tags?: Record<string, string>;\n release?: string;\n};\n\nlet config: ResolvedConfig | null = null;\nlet rateLimiter: RateLimiter | null = null;\nlet droppedByRateLimit = 0;\nlet staticContext: Partial<PionneEvent> = {};\nlet onUncaught: ((err: Error) => void) | null = null;\nlet onRejection: ((reason: unknown) => void) | null = null;\n\nfunction gatherStaticContext(): Partial<PionneEvent> {\n let timezone: string | undefined;\n try {\n timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n } catch {\n // ignore\n }\n return {\n os_name: os.type(),\n os_version: os.release(),\n timezone,\n contexts: {\n sdk: { name: SDK_NAME, version: SDK_VERSION },\n runtime: {\n name: 'node',\n version: process.versions.node,\n v8: process.versions.v8,\n },\n os: {\n name: os.type(),\n version: os.release(),\n platform: process.platform,\n arch: process.arch,\n cpu_count: os.cpus().length,\n total_memory: os.totalmem(),\n free_memory: os.freemem(),\n },\n app: {\n hostname: os.hostname(),\n pid: process.pid,\n cwd: process.cwd(),\n },\n },\n };\n}\n\nfunction parseStack(error: Error, max: number): string[] {\n if (!error.stack) return [];\n return error.stack\n .split('\\n')\n .slice(0, max)\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nfunction buildEvent(\n err: unknown,\n level: Level,\n mechanism: MechanismType,\n handled: boolean,\n extra?: Partial<PionneEvent>,\n): PionneEvent | null {\n if (!config || !config.enabled) return null;\n const e = err instanceof Error ? err : new Error(String(err));\n const event: PionneEvent = {\n ...staticContext,\n exception_type: e.name || 'Error',\n message: e.message || null,\n stack: parseStack(e, config.maxStackFrames),\n level,\n release: config.release,\n environment: config.environment,\n user_id_anon: config.userIdAnon,\n tags: config.tags,\n mechanism: { type: mechanism, handled },\n ...extra,\n };\n if (config.beforeSend) {\n const result = config.beforeSend(event);\n if (!result) return null;\n return result;\n }\n return event;\n}\n\nasync function send(event: PionneEvent): Promise<void> {\n if (rateLimiter && !rateLimiter.allow()) {\n droppedByRateLimit++;\n if (process.env.NODE_ENV !== 'production' && droppedByRateLimit % 50 === 1) {\n console.warn(`[Pionne] rate-limit reached (${droppedByRateLimit} events dropped). Bump maxEventsPerSecond if intentional.`);\n }\n return;\n }\n\n if (!config) return;\n try {\n // Node >=18 has global `fetch`. We rely on it instead of pulling in\n // node-fetch / undici as a dependency.\n if (typeof fetch !== 'function') return;\n await fetch(config.endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': config.token,\n },\n body: JSON.stringify(event),\n });\n } catch {\n // Best-effort: a monitoring SDK must never crash the host process.\n }\n}\n\nfunction installUncaughtHandler(): void {\n onUncaught = (err: Error) => {\n const event = buildEvent(err, 'fatal', 'uncaughtException', false);\n if (event) {\n // Fire-and-forget: process is going to die anyway. Best we can do is\n // try to flush before exit, but Node will tear down imminently.\n void send(event);\n flipFromEvent(event.level, event.mechanism?.type ?? 'uncaughtException');\n }\n };\n process.on('uncaughtException', onUncaught);\n}\n\nfunction installRejectionHandler(): void {\n onRejection = (reason: unknown) => {\n const err = reason instanceof Error ? reason : new Error(String(reason));\n const event = buildEvent(err, 'error', 'unhandledRejection', false);\n if (event) {\n void send(event);\n flipFromEvent(event.level, event.mechanism?.type ?? 'unhandledRejection');\n }\n };\n process.on('unhandledRejection', onRejection);\n}\n\nexport const Pionne = {\n init(options: PionneOptions): void {\n try {\n const isDev = process.env.NODE_ENV !== 'production';\n if (!options?.token || !validateToken(options.token)) {\n if (isDev) {\n console.warn('[Pionne] Missing or invalid token (expected pio_live_<≥16 chars>, no placeholders).');\n }\n return;\n }\n const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n if (!validateEndpoint(endpoint, isDev)) {\n console.warn('[Pionne] Refusing non-HTTPS endpoint in production:', endpoint);\n return;\n }\n const rps = options.maxEventsPerSecond ?? 10;\n rateLimiter = rps > 0 ? new RateLimiter(rps, rps) : null;\n\n const autoContext = options.autoContext ?? true;\n staticContext = autoContext ? gatherStaticContext() : {};\n\n config = {\n token: options.token,\n endpoint: options.endpoint ?? DEFAULT_ENDPOINT,\n release: options.release,\n environment:\n options.environment ?? process.env.NODE_ENV ?? 'production',\n enabled: options.enabled ?? true,\n captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,\n captureUnhandledRejections: options.captureUnhandledRejections ?? true,\n autoContext,\n beforeSend: options.beforeSend,\n userIdAnon: options.userIdAnon,\n tags: options.tags,\n maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n\n // Release Health — open a session unless the host opted out.\n if (options.releaseHealth !== false) {\n _startSession({\n endpoint: config.endpoint,\n token: config.token,\n release: config.release,\n environment: config.environment,\n appVersion: staticContext.app_version,\n osName: staticContext.os_name,\n userIdAnon: config.userIdAnon,\n });\n }\n } catch (e) {\n console.warn('[Pionne] init failed silently — monitoring disabled.', e);\n config = null;\n }\n },\n\n captureException(err: unknown, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n err,\n extra?.level ?? 'error',\n 'manual',\n true,\n extra,\n );\n if (event) void send(event);\n },\n\n captureMessage(message: string, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n new Error(message),\n extra?.level ?? 'info',\n 'manual',\n true,\n { exception_type: 'Message', ...extra },\n );\n if (event) void send(event);\n },\n\n setUser(userIdAnon: string | null): void {\n if (!config) return;\n config.userIdAnon = userIdAnon ?? undefined;\n },\n\n setTags(tags: Record<string, string> | null): void {\n if (!config) return;\n config.tags = tags ?? undefined;\n },\n\n setEnabled(enabled: boolean): void {\n if (!config) return;\n config.enabled = enabled;\n },\n\n /**\n * Detach all auto handlers. Useful in tests / CLI scripts that need a\n * clean shutdown. Re-init by calling `init()` again.\n */\n uninstall(): void {\n if (onUncaught) process.removeListener('uncaughtException', onUncaught);\n if (onRejection) process.removeListener('unhandledRejection', onRejection);\n onUncaught = null;\n onRejection = null;\n config = null;\n staticContext = {};\n },\n\n // ─── Release Health ───────────────────────────────────────────────────\n\n /** Manually end the current session (status='exited'). */\n endSession(): void {\n _endSession();\n },\n\n /** UUID of the current open session (for diagnostics). */\n getSessionId(): string | null {\n return getCurrentSessionId();\n },\n};\n\n/**\n * Express / Connect / NestJS error middleware. Reports the error then passes\n * it down the chain. Mount it AFTER your routes:\n *\n * import { Pionne, expressErrorHandler } from '@pionne/node';\n * app.use(expressErrorHandler);\n * // your fallback error handler here\n */\nexport function expressErrorHandler(\n err: unknown,\n _req: unknown,\n _res: unknown,\n next: (err?: unknown) => void,\n): void {\n Pionne.captureException(err);\n next(err);\n}\n","// Mirror of @pionne/react-native and @pionne/web security guards.\n// Node-specific tweak: the \"localhost\" rule for non-HTTPS endpoints\n// also accepts hosts ending in `.local` (mDNS) — useful for staging\n// boxes behind Tailscale/ZeroTier.\n\nconst TOKEN_PREFIX = 'pio_live_';\nconst MIN_TOKEN_LENGTH = TOKEN_PREFIX.length + 16;\n\nexport function validateEndpoint(endpoint: string, isDev: boolean): boolean {\n try {\n const u = new URL(endpoint);\n if (u.protocol === 'https:') return true;\n if (u.protocol !== 'http:') return false;\n if (!isDev) return false;\n return /^(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]|.*\\.local)$/.test(u.hostname);\n } catch {\n return false;\n }\n}\n\nexport function validateToken(token: string): boolean {\n if (typeof token !== 'string') return false;\n if (!token.startsWith(TOKEN_PREFIX)) return false;\n if (token.length < MIN_TOKEN_LENGTH) return false;\n const lower = token.toLowerCase();\n for (const bad of ['xxx', 'yyy', 'todo', 'fixme', 'replace', 'changeme']) {\n if (lower.includes(bad)) return false;\n }\n return true;\n}\n\nexport class RateLimiter {\n private tokens: number;\n private lastRefill: number;\n constructor(\n private capacity: number,\n private refillPerSecond: number,\n ) {\n this.tokens = capacity;\n this.lastRefill = Date.now();\n }\n allow(): boolean {\n if (this.refillPerSecond <= 0) return true;\n const now = Date.now();\n const elapsedMs = now - this.lastRefill;\n if (elapsedMs > 0) {\n const refill = (elapsedMs / 1000) * this.refillPerSecond;\n this.tokens = Math.min(this.capacity, this.tokens + refill);\n this.lastRefill = now;\n }\n if (this.tokens >= 1) {\n this.tokens -= 1;\n return true;\n }\n return false;\n }\n}\n","// Release Health for Node.js. Same protocol as the browser/RN SDKs.\n// We auto-flip on uncaughtException/unhandledRejection through the host\n// SDK's existing handlers, and best-effort-emit 'exited' on process.exit.\n\nimport * as crypto from 'node:crypto';\n\nexport type SessionStatus = 'ok' | 'crashed' | 'errored' | 'abnormal' | 'exited';\n\nexport interface SessionContext {\n endpoint: string;\n token: string;\n release?: string;\n environment?: string;\n appVersion?: string;\n osName?: string;\n userIdAnon?: string;\n}\n\ninterface SessionState {\n id: string;\n startedAt: number;\n status: SessionStatus;\n ctx: SessionContext;\n}\n\nlet current: SessionState | null = null;\n\nfunction sessionsUrl(ingestEndpoint: string): string {\n if (ingestEndpoint.endsWith('/ingest')) {\n return ingestEndpoint.slice(0, -'/ingest'.length) + '/sessions';\n }\n return ingestEndpoint.replace(/\\/+$/, '') + '/sessions';\n}\n\nfunction postSession(state: SessionState, status: SessionStatus, durationMs?: number): void {\n const url = sessionsUrl(state.ctx.endpoint);\n const body = {\n session_id: state.id,\n status,\n release: state.ctx.release,\n environment: state.ctx.environment,\n app_version: state.ctx.appVersion,\n os_name: state.ctx.osName,\n user_id_anon: state.ctx.userIdAnon,\n duration_ms: durationMs,\n };\n for (const k of Object.keys(body) as (keyof typeof body)[]) {\n if (body[k] === undefined) delete body[k];\n }\n // Node 18+ has global fetch.\n fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': state.ctx.token,\n },\n body: JSON.stringify(body),\n }).catch(() => undefined);\n}\n\nexport function startSession(ctx: SessionContext): string {\n current = {\n id: crypto.randomUUID(),\n startedAt: Date.now(),\n status: 'ok',\n ctx,\n };\n postSession(current, 'ok');\n\n // Best-effort 'exited' flip on graceful shutdown. We listen on 'exit'\n // (sync only — no async I/O guaranteed) and 'beforeExit' (where async\n // works). The actual session POST is fire-and-forget anyway.\n const onShutdown = () => {\n if (!current || current.status !== 'ok') return;\n flipSession('exited');\n };\n process.once('beforeExit', onShutdown);\n\n return current.id;\n}\n\nexport function flipSession(status: SessionStatus): void {\n if (!current) return;\n const rank: Record<SessionStatus, number> =\n { ok: 0, exited: 1, errored: 2, abnormal: 3, crashed: 4 };\n if (rank[status] <= rank[current.status]) return;\n current.status = status;\n postSession(current, status, Date.now() - current.startedAt);\n}\n\nexport function endSession(status: SessionStatus = 'exited'): void {\n if (!current) return;\n flipSession(status);\n current = null;\n}\n\nexport function getCurrentSessionId(): string | null {\n return current?.id ?? null;\n}\n\nexport function flipFromEvent(\n level: 'fatal' | 'error' | 'warning' | 'info' | undefined,\n mechanismType: string,\n): void {\n if (mechanismType === 'manual') return;\n if (level === 'fatal') flipSession('crashed');\n else if (level === 'error') flipSession('errored');\n}\n"],"mappings":"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","names":["process"]}
|
package/dist/index.d.mts
CHANGED
|
@@ -35,6 +35,14 @@ interface PionneOptions {
|
|
|
35
35
|
userIdAnon?: string;
|
|
36
36
|
tags?: Record<string, string>;
|
|
37
37
|
maxStackFrames?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Release Health — opens a session at init() with status='ok', flips to
|
|
40
|
+
* 'crashed'/'errored' if a fatal/error fires through the global handlers.
|
|
41
|
+
* The dashboard derives crash-free user rate per release. Default: true.
|
|
42
|
+
*/
|
|
43
|
+
releaseHealth?: boolean;
|
|
44
|
+
/** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
|
|
45
|
+
maxEventsPerSecond?: number;
|
|
38
46
|
}
|
|
39
47
|
declare const Pionne: {
|
|
40
48
|
init(options: PionneOptions): void;
|
|
@@ -48,6 +56,10 @@ declare const Pionne: {
|
|
|
48
56
|
* clean shutdown. Re-init by calling `init()` again.
|
|
49
57
|
*/
|
|
50
58
|
uninstall(): void;
|
|
59
|
+
/** Manually end the current session (status='exited'). */
|
|
60
|
+
endSession(): void;
|
|
61
|
+
/** UUID of the current open session (for diagnostics). */
|
|
62
|
+
getSessionId(): string | null;
|
|
51
63
|
};
|
|
52
64
|
/**
|
|
53
65
|
* Express / Connect / NestJS error middleware. Reports the error then passes
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ interface PionneOptions {
|
|
|
35
35
|
userIdAnon?: string;
|
|
36
36
|
tags?: Record<string, string>;
|
|
37
37
|
maxStackFrames?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Release Health — opens a session at init() with status='ok', flips to
|
|
40
|
+
* 'crashed'/'errored' if a fatal/error fires through the global handlers.
|
|
41
|
+
* The dashboard derives crash-free user rate per release. Default: true.
|
|
42
|
+
*/
|
|
43
|
+
releaseHealth?: boolean;
|
|
44
|
+
/** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
|
|
45
|
+
maxEventsPerSecond?: number;
|
|
38
46
|
}
|
|
39
47
|
declare const Pionne: {
|
|
40
48
|
init(options: PionneOptions): void;
|
|
@@ -48,6 +56,10 @@ declare const Pionne: {
|
|
|
48
56
|
* clean shutdown. Re-init by calling `init()` again.
|
|
49
57
|
*/
|
|
50
58
|
uninstall(): void;
|
|
59
|
+
/** Manually end the current session (status='exited'). */
|
|
60
|
+
endSession(): void;
|
|
61
|
+
/** UUID of the current open session (for diagnostics). */
|
|
62
|
+
getSessionId(): string | null;
|
|
51
63
|
};
|
|
52
64
|
/**
|
|
53
65
|
* Express / Connect / NestJS error middleware. Reports the error then passes
|
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,132 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import * as os from "os";
|
|
3
|
-
import * as
|
|
3
|
+
import * as process2 from "process";
|
|
4
|
+
|
|
5
|
+
// src/security.ts
|
|
6
|
+
var TOKEN_PREFIX = "pio_live_";
|
|
7
|
+
var MIN_TOKEN_LENGTH = TOKEN_PREFIX.length + 16;
|
|
8
|
+
function validateEndpoint(endpoint, isDev) {
|
|
9
|
+
try {
|
|
10
|
+
const u = new URL(endpoint);
|
|
11
|
+
if (u.protocol === "https:") return true;
|
|
12
|
+
if (u.protocol !== "http:") return false;
|
|
13
|
+
if (!isDev) return false;
|
|
14
|
+
return /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|.*\.local)$/.test(u.hostname);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function validateToken(token) {
|
|
20
|
+
if (typeof token !== "string") return false;
|
|
21
|
+
if (!token.startsWith(TOKEN_PREFIX)) return false;
|
|
22
|
+
if (token.length < MIN_TOKEN_LENGTH) return false;
|
|
23
|
+
const lower = token.toLowerCase();
|
|
24
|
+
for (const bad of ["xxx", "yyy", "todo", "fixme", "replace", "changeme"]) {
|
|
25
|
+
if (lower.includes(bad)) return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
var RateLimiter = class {
|
|
30
|
+
constructor(capacity, refillPerSecond) {
|
|
31
|
+
this.capacity = capacity;
|
|
32
|
+
this.refillPerSecond = refillPerSecond;
|
|
33
|
+
this.tokens = capacity;
|
|
34
|
+
this.lastRefill = Date.now();
|
|
35
|
+
}
|
|
36
|
+
allow() {
|
|
37
|
+
if (this.refillPerSecond <= 0) return true;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const elapsedMs = now - this.lastRefill;
|
|
40
|
+
if (elapsedMs > 0) {
|
|
41
|
+
const refill = elapsedMs / 1e3 * this.refillPerSecond;
|
|
42
|
+
this.tokens = Math.min(this.capacity, this.tokens + refill);
|
|
43
|
+
this.lastRefill = now;
|
|
44
|
+
}
|
|
45
|
+
if (this.tokens >= 1) {
|
|
46
|
+
this.tokens -= 1;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/sessions.ts
|
|
54
|
+
import * as crypto from "crypto";
|
|
55
|
+
var current = null;
|
|
56
|
+
function sessionsUrl(ingestEndpoint) {
|
|
57
|
+
if (ingestEndpoint.endsWith("/ingest")) {
|
|
58
|
+
return ingestEndpoint.slice(0, -"/ingest".length) + "/sessions";
|
|
59
|
+
}
|
|
60
|
+
return ingestEndpoint.replace(/\/+$/, "") + "/sessions";
|
|
61
|
+
}
|
|
62
|
+
function postSession(state, status, durationMs) {
|
|
63
|
+
const url = sessionsUrl(state.ctx.endpoint);
|
|
64
|
+
const body = {
|
|
65
|
+
session_id: state.id,
|
|
66
|
+
status,
|
|
67
|
+
release: state.ctx.release,
|
|
68
|
+
environment: state.ctx.environment,
|
|
69
|
+
app_version: state.ctx.appVersion,
|
|
70
|
+
os_name: state.ctx.osName,
|
|
71
|
+
user_id_anon: state.ctx.userIdAnon,
|
|
72
|
+
duration_ms: durationMs
|
|
73
|
+
};
|
|
74
|
+
for (const k of Object.keys(body)) {
|
|
75
|
+
if (body[k] === void 0) delete body[k];
|
|
76
|
+
}
|
|
77
|
+
fetch(url, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"X-Pionne-Token": state.ctx.token
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify(body)
|
|
84
|
+
}).catch(() => void 0);
|
|
85
|
+
}
|
|
86
|
+
function startSession(ctx) {
|
|
87
|
+
current = {
|
|
88
|
+
id: crypto.randomUUID(),
|
|
89
|
+
startedAt: Date.now(),
|
|
90
|
+
status: "ok",
|
|
91
|
+
ctx
|
|
92
|
+
};
|
|
93
|
+
postSession(current, "ok");
|
|
94
|
+
const onShutdown = () => {
|
|
95
|
+
if (!current || current.status !== "ok") return;
|
|
96
|
+
flipSession("exited");
|
|
97
|
+
};
|
|
98
|
+
process.once("beforeExit", onShutdown);
|
|
99
|
+
return current.id;
|
|
100
|
+
}
|
|
101
|
+
function flipSession(status) {
|
|
102
|
+
if (!current) return;
|
|
103
|
+
const rank = { ok: 0, exited: 1, errored: 2, abnormal: 3, crashed: 4 };
|
|
104
|
+
if (rank[status] <= rank[current.status]) return;
|
|
105
|
+
current.status = status;
|
|
106
|
+
postSession(current, status, Date.now() - current.startedAt);
|
|
107
|
+
}
|
|
108
|
+
function endSession(status = "exited") {
|
|
109
|
+
if (!current) return;
|
|
110
|
+
flipSession(status);
|
|
111
|
+
current = null;
|
|
112
|
+
}
|
|
113
|
+
function getCurrentSessionId() {
|
|
114
|
+
return current?.id ?? null;
|
|
115
|
+
}
|
|
116
|
+
function flipFromEvent(level, mechanismType) {
|
|
117
|
+
if (mechanismType === "manual") return;
|
|
118
|
+
if (level === "fatal") flipSession("crashed");
|
|
119
|
+
else if (level === "error") flipSession("errored");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/index.ts
|
|
4
123
|
var DEFAULT_ENDPOINT = "https://pionne.agkgcreations.fr/api/ingest";
|
|
5
124
|
var DEFAULT_MAX_STACK = 50;
|
|
6
125
|
var SDK_NAME = "pionne.node";
|
|
7
126
|
var SDK_VERSION = "0.1.0";
|
|
8
127
|
var config = null;
|
|
128
|
+
var rateLimiter = null;
|
|
129
|
+
var droppedByRateLimit = 0;
|
|
9
130
|
var staticContext = {};
|
|
10
131
|
var onUncaught = null;
|
|
11
132
|
var onRejection = null;
|
|
@@ -23,22 +144,22 @@ function gatherStaticContext() {
|
|
|
23
144
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
24
145
|
runtime: {
|
|
25
146
|
name: "node",
|
|
26
|
-
version:
|
|
27
|
-
v8:
|
|
147
|
+
version: process2.versions.node,
|
|
148
|
+
v8: process2.versions.v8
|
|
28
149
|
},
|
|
29
150
|
os: {
|
|
30
151
|
name: os.type(),
|
|
31
152
|
version: os.release(),
|
|
32
|
-
platform:
|
|
33
|
-
arch:
|
|
153
|
+
platform: process2.platform,
|
|
154
|
+
arch: process2.arch,
|
|
34
155
|
cpu_count: os.cpus().length,
|
|
35
156
|
total_memory: os.totalmem(),
|
|
36
157
|
free_memory: os.freemem()
|
|
37
158
|
},
|
|
38
159
|
app: {
|
|
39
160
|
hostname: os.hostname(),
|
|
40
|
-
pid:
|
|
41
|
-
cwd:
|
|
161
|
+
pid: process2.pid,
|
|
162
|
+
cwd: process2.cwd()
|
|
42
163
|
}
|
|
43
164
|
}
|
|
44
165
|
};
|
|
@@ -71,6 +192,13 @@ function buildEvent(err, level, mechanism, handled, extra) {
|
|
|
71
192
|
return event;
|
|
72
193
|
}
|
|
73
194
|
async function send(event) {
|
|
195
|
+
if (rateLimiter && !rateLimiter.allow()) {
|
|
196
|
+
droppedByRateLimit++;
|
|
197
|
+
if (process2.env.NODE_ENV !== "production" && droppedByRateLimit % 50 === 1) {
|
|
198
|
+
console.warn(`[Pionne] rate-limit reached (${droppedByRateLimit} events dropped). Bump maxEventsPerSecond if intentional.`);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
74
202
|
if (!config) return;
|
|
75
203
|
try {
|
|
76
204
|
if (typeof fetch !== "function") return;
|
|
@@ -90,46 +218,72 @@ function installUncaughtHandler() {
|
|
|
90
218
|
const event = buildEvent(err, "fatal", "uncaughtException", false);
|
|
91
219
|
if (event) {
|
|
92
220
|
void send(event);
|
|
221
|
+
flipFromEvent(event.level, event.mechanism?.type ?? "uncaughtException");
|
|
93
222
|
}
|
|
94
223
|
};
|
|
95
|
-
|
|
224
|
+
process2.on("uncaughtException", onUncaught);
|
|
96
225
|
}
|
|
97
226
|
function installRejectionHandler() {
|
|
98
227
|
onRejection = (reason) => {
|
|
99
228
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
100
229
|
const event = buildEvent(err, "error", "unhandledRejection", false);
|
|
101
|
-
if (event)
|
|
230
|
+
if (event) {
|
|
231
|
+
void send(event);
|
|
232
|
+
flipFromEvent(event.level, event.mechanism?.type ?? "unhandledRejection");
|
|
233
|
+
}
|
|
102
234
|
};
|
|
103
|
-
|
|
235
|
+
process2.on("unhandledRejection", onRejection);
|
|
104
236
|
}
|
|
105
237
|
var Pionne = {
|
|
106
238
|
init(options) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
239
|
+
try {
|
|
240
|
+
const isDev = process2.env.NODE_ENV !== "production";
|
|
241
|
+
if (!options?.token || !validateToken(options.token)) {
|
|
242
|
+
if (isDev) {
|
|
243
|
+
console.warn("[Pionne] Missing or invalid token (expected pio_live_<\u226516 chars>, no placeholders).");
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
112
246
|
}
|
|
113
|
-
|
|
247
|
+
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
248
|
+
if (!validateEndpoint(endpoint, isDev)) {
|
|
249
|
+
console.warn("[Pionne] Refusing non-HTTPS endpoint in production:", endpoint);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const rps = options.maxEventsPerSecond ?? 10;
|
|
253
|
+
rateLimiter = rps > 0 ? new RateLimiter(rps, rps) : null;
|
|
254
|
+
const autoContext = options.autoContext ?? true;
|
|
255
|
+
staticContext = autoContext ? gatherStaticContext() : {};
|
|
256
|
+
config = {
|
|
257
|
+
token: options.token,
|
|
258
|
+
endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
|
|
259
|
+
release: options.release,
|
|
260
|
+
environment: options.environment ?? process2.env.NODE_ENV ?? "production",
|
|
261
|
+
enabled: options.enabled ?? true,
|
|
262
|
+
captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
|
|
263
|
+
captureUnhandledRejections: options.captureUnhandledRejections ?? true,
|
|
264
|
+
autoContext,
|
|
265
|
+
beforeSend: options.beforeSend,
|
|
266
|
+
userIdAnon: options.userIdAnon,
|
|
267
|
+
tags: options.tags,
|
|
268
|
+
maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
|
|
269
|
+
};
|
|
270
|
+
if (config.captureUncaughtExceptions) installUncaughtHandler();
|
|
271
|
+
if (config.captureUnhandledRejections) installRejectionHandler();
|
|
272
|
+
if (options.releaseHealth !== false) {
|
|
273
|
+
startSession({
|
|
274
|
+
endpoint: config.endpoint,
|
|
275
|
+
token: config.token,
|
|
276
|
+
release: config.release,
|
|
277
|
+
environment: config.environment,
|
|
278
|
+
appVersion: staticContext.app_version,
|
|
279
|
+
osName: staticContext.os_name,
|
|
280
|
+
userIdAnon: config.userIdAnon
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.warn("[Pionne] init failed silently \u2014 monitoring disabled.", e);
|
|
285
|
+
config = null;
|
|
114
286
|
}
|
|
115
|
-
const autoContext = options.autoContext ?? true;
|
|
116
|
-
staticContext = autoContext ? gatherStaticContext() : {};
|
|
117
|
-
config = {
|
|
118
|
-
token: options.token,
|
|
119
|
-
endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
|
|
120
|
-
release: options.release,
|
|
121
|
-
environment: options.environment ?? process.env.NODE_ENV ?? "production",
|
|
122
|
-
enabled: options.enabled ?? true,
|
|
123
|
-
captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
|
|
124
|
-
captureUnhandledRejections: options.captureUnhandledRejections ?? true,
|
|
125
|
-
autoContext,
|
|
126
|
-
beforeSend: options.beforeSend,
|
|
127
|
-
userIdAnon: options.userIdAnon,
|
|
128
|
-
tags: options.tags,
|
|
129
|
-
maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
|
|
130
|
-
};
|
|
131
|
-
if (config.captureUncaughtExceptions) installUncaughtHandler();
|
|
132
|
-
if (config.captureUnhandledRejections) installRejectionHandler();
|
|
133
287
|
},
|
|
134
288
|
captureException(err, extra) {
|
|
135
289
|
const event = buildEvent(
|
|
@@ -168,12 +322,21 @@ var Pionne = {
|
|
|
168
322
|
* clean shutdown. Re-init by calling `init()` again.
|
|
169
323
|
*/
|
|
170
324
|
uninstall() {
|
|
171
|
-
if (onUncaught)
|
|
172
|
-
if (onRejection)
|
|
325
|
+
if (onUncaught) process2.removeListener("uncaughtException", onUncaught);
|
|
326
|
+
if (onRejection) process2.removeListener("unhandledRejection", onRejection);
|
|
173
327
|
onUncaught = null;
|
|
174
328
|
onRejection = null;
|
|
175
329
|
config = null;
|
|
176
330
|
staticContext = {};
|
|
331
|
+
},
|
|
332
|
+
// ─── Release Health ───────────────────────────────────────────────────
|
|
333
|
+
/** Manually end the current session (status='exited'). */
|
|
334
|
+
endSession() {
|
|
335
|
+
endSession();
|
|
336
|
+
},
|
|
337
|
+
/** UUID of the current open session (for diagnostics). */
|
|
338
|
+
getSessionId() {
|
|
339
|
+
return getCurrentSessionId();
|
|
177
340
|
}
|
|
178
341
|
};
|
|
179
342
|
function expressErrorHandler(err, _req, _res, next) {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\nexport type Level = 'fatal' | 'error' | 'warning' | 'info';\nexport type MechanismType =\n | 'uncaughtException'\n | 'unhandledRejection'\n | 'manual';\n\nexport interface Mechanism {\n type: MechanismType;\n handled: boolean;\n}\n\nexport interface PionneEvent {\n exception_type: string;\n message?: string | null;\n stack?: string[];\n level?: Level;\n\n release?: string;\n environment?: string;\n app_version?: string;\n os_name?: string;\n os_version?: string;\n user_id_anon?: string;\n locale?: string;\n timezone?: string;\n\n contexts?: Record<string, Record<string, unknown> | undefined>;\n mechanism?: Mechanism;\n tags?: Record<string, string>;\n}\n\nexport interface PionneOptions {\n /** Project token (starts with `pio_live_`). Required. */\n token: string;\n endpoint?: string;\n release?: string;\n environment?: string;\n enabled?: boolean;\n captureUncaughtExceptions?: boolean;\n captureUnhandledRejections?: boolean;\n autoContext?: boolean;\n beforeSend?: (event: PionneEvent) => PionneEvent | null;\n userIdAnon?: string;\n tags?: Record<string, string>;\n maxStackFrames?: number;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_MAX_STACK = 50;\nconst SDK_NAME = 'pionne.node';\nconst SDK_VERSION = '0.1.0';\n\ntype ResolvedConfig = Required<\n Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release'>\n> & {\n beforeSend?: PionneOptions['beforeSend'];\n userIdAnon?: string;\n tags?: Record<string, string>;\n release?: string;\n};\n\nlet config: ResolvedConfig | null = null;\nlet staticContext: Partial<PionneEvent> = {};\nlet onUncaught: ((err: Error) => void) | null = null;\nlet onRejection: ((reason: unknown) => void) | null = null;\n\nfunction gatherStaticContext(): Partial<PionneEvent> {\n let timezone: string | undefined;\n try {\n timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n } catch {\n // ignore\n }\n return {\n os_name: os.type(),\n os_version: os.release(),\n timezone,\n contexts: {\n sdk: { name: SDK_NAME, version: SDK_VERSION },\n runtime: {\n name: 'node',\n version: process.versions.node,\n v8: process.versions.v8,\n },\n os: {\n name: os.type(),\n version: os.release(),\n platform: process.platform,\n arch: process.arch,\n cpu_count: os.cpus().length,\n total_memory: os.totalmem(),\n free_memory: os.freemem(),\n },\n app: {\n hostname: os.hostname(),\n pid: process.pid,\n cwd: process.cwd(),\n },\n },\n };\n}\n\nfunction parseStack(error: Error, max: number): string[] {\n if (!error.stack) return [];\n return error.stack\n .split('\\n')\n .slice(0, max)\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nfunction buildEvent(\n err: unknown,\n level: Level,\n mechanism: MechanismType,\n handled: boolean,\n extra?: Partial<PionneEvent>,\n): PionneEvent | null {\n if (!config || !config.enabled) return null;\n const e = err instanceof Error ? err : new Error(String(err));\n const event: PionneEvent = {\n ...staticContext,\n exception_type: e.name || 'Error',\n message: e.message || null,\n stack: parseStack(e, config.maxStackFrames),\n level,\n release: config.release,\n environment: config.environment,\n user_id_anon: config.userIdAnon,\n tags: config.tags,\n mechanism: { type: mechanism, handled },\n ...extra,\n };\n if (config.beforeSend) {\n const result = config.beforeSend(event);\n if (!result) return null;\n return result;\n }\n return event;\n}\n\nasync function send(event: PionneEvent): Promise<void> {\n if (!config) return;\n try {\n // Node >=18 has global `fetch`. We rely on it instead of pulling in\n // node-fetch / undici as a dependency.\n if (typeof fetch !== 'function') return;\n await fetch(config.endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': config.token,\n },\n body: JSON.stringify(event),\n });\n } catch {\n // Best-effort: a monitoring SDK must never crash the host process.\n }\n}\n\nfunction installUncaughtHandler(): void {\n onUncaught = (err: Error) => {\n const event = buildEvent(err, 'fatal', 'uncaughtException', false);\n if (event) {\n // Fire-and-forget: process is going to die anyway. Best we can do is\n // try to flush before exit, but Node will tear down imminently.\n void send(event);\n }\n };\n process.on('uncaughtException', onUncaught);\n}\n\nfunction installRejectionHandler(): void {\n onRejection = (reason: unknown) => {\n const err = reason instanceof Error ? reason : new Error(String(reason));\n const event = buildEvent(err, 'error', 'unhandledRejection', false);\n if (event) void send(event);\n };\n process.on('unhandledRejection', onRejection);\n}\n\nexport const Pionne = {\n init(options: PionneOptions): void {\n if (!options?.token || !options.token.startsWith('pio_live_')) {\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n '[Pionne] Missing or invalid token (must start with pio_live_).',\n );\n }\n return;\n }\n\n const autoContext = options.autoContext ?? true;\n staticContext = autoContext ? gatherStaticContext() : {};\n\n config = {\n token: options.token,\n endpoint: options.endpoint ?? DEFAULT_ENDPOINT,\n release: options.release,\n environment:\n options.environment ?? process.env.NODE_ENV ?? 'production',\n enabled: options.enabled ?? true,\n captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,\n captureUnhandledRejections: options.captureUnhandledRejections ?? true,\n autoContext,\n beforeSend: options.beforeSend,\n userIdAnon: options.userIdAnon,\n tags: options.tags,\n maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n },\n\n captureException(err: unknown, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n err,\n extra?.level ?? 'error',\n 'manual',\n true,\n extra,\n );\n if (event) void send(event);\n },\n\n captureMessage(message: string, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n new Error(message),\n extra?.level ?? 'info',\n 'manual',\n true,\n { exception_type: 'Message', ...extra },\n );\n if (event) void send(event);\n },\n\n setUser(userIdAnon: string | null): void {\n if (!config) return;\n config.userIdAnon = userIdAnon ?? undefined;\n },\n\n setTags(tags: Record<string, string> | null): void {\n if (!config) return;\n config.tags = tags ?? undefined;\n },\n\n setEnabled(enabled: boolean): void {\n if (!config) return;\n config.enabled = enabled;\n },\n\n /**\n * Detach all auto handlers. Useful in tests / CLI scripts that need a\n * clean shutdown. Re-init by calling `init()` again.\n */\n uninstall(): void {\n if (onUncaught) process.removeListener('uncaughtException', onUncaught);\n if (onRejection) process.removeListener('unhandledRejection', onRejection);\n onUncaught = null;\n onRejection = null;\n config = null;\n staticContext = {};\n },\n};\n\n/**\n * Express / Connect / NestJS error middleware. Reports the error then passes\n * it down the chain. Mount it AFTER your routes:\n *\n * import { Pionne, expressErrorHandler } from '@pionne/node';\n * app.use(expressErrorHandler);\n * // your fallback error handler here\n */\nexport function expressErrorHandler(\n err: unknown,\n _req: unknown,\n _res: unknown,\n next: (err?: unknown) => void,\n): void {\n Pionne.captureException(err);\n next(err);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/security.ts","../src/sessions.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\nimport { RateLimiter, validateEndpoint, validateToken } from './security';\nimport {\n endSession as _endSession,\n flipFromEvent,\n getCurrentSessionId,\n startSession as _startSession,\n} from './sessions';\n\nexport type Level = 'fatal' | 'error' | 'warning' | 'info';\nexport type MechanismType =\n | 'uncaughtException'\n | 'unhandledRejection'\n | 'manual';\n\nexport interface Mechanism {\n type: MechanismType;\n handled: boolean;\n}\n\nexport interface PionneEvent {\n exception_type: string;\n message?: string | null;\n stack?: string[];\n level?: Level;\n\n release?: string;\n environment?: string;\n app_version?: string;\n os_name?: string;\n os_version?: string;\n user_id_anon?: string;\n locale?: string;\n timezone?: string;\n\n contexts?: Record<string, Record<string, unknown> | undefined>;\n mechanism?: Mechanism;\n tags?: Record<string, string>;\n}\n\nexport interface PionneOptions {\n /** Project token (starts with `pio_live_`). Required. */\n token: string;\n endpoint?: string;\n release?: string;\n environment?: string;\n enabled?: boolean;\n captureUncaughtExceptions?: boolean;\n captureUnhandledRejections?: boolean;\n autoContext?: boolean;\n beforeSend?: (event: PionneEvent) => PionneEvent | null;\n userIdAnon?: string;\n tags?: Record<string, string>;\n maxStackFrames?: number;\n /**\n * Release Health — opens a session at init() with status='ok', flips to\n * 'crashed'/'errored' if a fatal/error fires through the global handlers.\n * The dashboard derives crash-free user rate per release. Default: true.\n */\n releaseHealth?: boolean;\n /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */\n maxEventsPerSecond?: number;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_MAX_STACK = 50;\nconst SDK_NAME = 'pionne.node';\nconst SDK_VERSION = '0.1.0';\n\ntype ResolvedConfig = Required<\n Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release' | 'releaseHealth' | 'maxEventsPerSecond'>\n> & {\n beforeSend?: PionneOptions['beforeSend'];\n userIdAnon?: string;\n tags?: Record<string, string>;\n release?: string;\n};\n\nlet config: ResolvedConfig | null = null;\nlet rateLimiter: RateLimiter | null = null;\nlet droppedByRateLimit = 0;\nlet staticContext: Partial<PionneEvent> = {};\nlet onUncaught: ((err: Error) => void) | null = null;\nlet onRejection: ((reason: unknown) => void) | null = null;\n\nfunction gatherStaticContext(): Partial<PionneEvent> {\n let timezone: string | undefined;\n try {\n timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n } catch {\n // ignore\n }\n return {\n os_name: os.type(),\n os_version: os.release(),\n timezone,\n contexts: {\n sdk: { name: SDK_NAME, version: SDK_VERSION },\n runtime: {\n name: 'node',\n version: process.versions.node,\n v8: process.versions.v8,\n },\n os: {\n name: os.type(),\n version: os.release(),\n platform: process.platform,\n arch: process.arch,\n cpu_count: os.cpus().length,\n total_memory: os.totalmem(),\n free_memory: os.freemem(),\n },\n app: {\n hostname: os.hostname(),\n pid: process.pid,\n cwd: process.cwd(),\n },\n },\n };\n}\n\nfunction parseStack(error: Error, max: number): string[] {\n if (!error.stack) return [];\n return error.stack\n .split('\\n')\n .slice(0, max)\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nfunction buildEvent(\n err: unknown,\n level: Level,\n mechanism: MechanismType,\n handled: boolean,\n extra?: Partial<PionneEvent>,\n): PionneEvent | null {\n if (!config || !config.enabled) return null;\n const e = err instanceof Error ? err : new Error(String(err));\n const event: PionneEvent = {\n ...staticContext,\n exception_type: e.name || 'Error',\n message: e.message || null,\n stack: parseStack(e, config.maxStackFrames),\n level,\n release: config.release,\n environment: config.environment,\n user_id_anon: config.userIdAnon,\n tags: config.tags,\n mechanism: { type: mechanism, handled },\n ...extra,\n };\n if (config.beforeSend) {\n const result = config.beforeSend(event);\n if (!result) return null;\n return result;\n }\n return event;\n}\n\nasync function send(event: PionneEvent): Promise<void> {\n if (rateLimiter && !rateLimiter.allow()) {\n droppedByRateLimit++;\n if (process.env.NODE_ENV !== 'production' && droppedByRateLimit % 50 === 1) {\n console.warn(`[Pionne] rate-limit reached (${droppedByRateLimit} events dropped). Bump maxEventsPerSecond if intentional.`);\n }\n return;\n }\n\n if (!config) return;\n try {\n // Node >=18 has global `fetch`. We rely on it instead of pulling in\n // node-fetch / undici as a dependency.\n if (typeof fetch !== 'function') return;\n await fetch(config.endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': config.token,\n },\n body: JSON.stringify(event),\n });\n } catch {\n // Best-effort: a monitoring SDK must never crash the host process.\n }\n}\n\nfunction installUncaughtHandler(): void {\n onUncaught = (err: Error) => {\n const event = buildEvent(err, 'fatal', 'uncaughtException', false);\n if (event) {\n // Fire-and-forget: process is going to die anyway. Best we can do is\n // try to flush before exit, but Node will tear down imminently.\n void send(event);\n flipFromEvent(event.level, event.mechanism?.type ?? 'uncaughtException');\n }\n };\n process.on('uncaughtException', onUncaught);\n}\n\nfunction installRejectionHandler(): void {\n onRejection = (reason: unknown) => {\n const err = reason instanceof Error ? reason : new Error(String(reason));\n const event = buildEvent(err, 'error', 'unhandledRejection', false);\n if (event) {\n void send(event);\n flipFromEvent(event.level, event.mechanism?.type ?? 'unhandledRejection');\n }\n };\n process.on('unhandledRejection', onRejection);\n}\n\nexport const Pionne = {\n init(options: PionneOptions): void {\n try {\n const isDev = process.env.NODE_ENV !== 'production';\n if (!options?.token || !validateToken(options.token)) {\n if (isDev) {\n console.warn('[Pionne] Missing or invalid token (expected pio_live_<≥16 chars>, no placeholders).');\n }\n return;\n }\n const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n if (!validateEndpoint(endpoint, isDev)) {\n console.warn('[Pionne] Refusing non-HTTPS endpoint in production:', endpoint);\n return;\n }\n const rps = options.maxEventsPerSecond ?? 10;\n rateLimiter = rps > 0 ? new RateLimiter(rps, rps) : null;\n\n const autoContext = options.autoContext ?? true;\n staticContext = autoContext ? gatherStaticContext() : {};\n\n config = {\n token: options.token,\n endpoint: options.endpoint ?? DEFAULT_ENDPOINT,\n release: options.release,\n environment:\n options.environment ?? process.env.NODE_ENV ?? 'production',\n enabled: options.enabled ?? true,\n captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,\n captureUnhandledRejections: options.captureUnhandledRejections ?? true,\n autoContext,\n beforeSend: options.beforeSend,\n userIdAnon: options.userIdAnon,\n tags: options.tags,\n maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n\n // Release Health — open a session unless the host opted out.\n if (options.releaseHealth !== false) {\n _startSession({\n endpoint: config.endpoint,\n token: config.token,\n release: config.release,\n environment: config.environment,\n appVersion: staticContext.app_version,\n osName: staticContext.os_name,\n userIdAnon: config.userIdAnon,\n });\n }\n } catch (e) {\n console.warn('[Pionne] init failed silently — monitoring disabled.', e);\n config = null;\n }\n },\n\n captureException(err: unknown, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n err,\n extra?.level ?? 'error',\n 'manual',\n true,\n extra,\n );\n if (event) void send(event);\n },\n\n captureMessage(message: string, extra?: Partial<PionneEvent>): void {\n const event = buildEvent(\n new Error(message),\n extra?.level ?? 'info',\n 'manual',\n true,\n { exception_type: 'Message', ...extra },\n );\n if (event) void send(event);\n },\n\n setUser(userIdAnon: string | null): void {\n if (!config) return;\n config.userIdAnon = userIdAnon ?? undefined;\n },\n\n setTags(tags: Record<string, string> | null): void {\n if (!config) return;\n config.tags = tags ?? undefined;\n },\n\n setEnabled(enabled: boolean): void {\n if (!config) return;\n config.enabled = enabled;\n },\n\n /**\n * Detach all auto handlers. Useful in tests / CLI scripts that need a\n * clean shutdown. Re-init by calling `init()` again.\n */\n uninstall(): void {\n if (onUncaught) process.removeListener('uncaughtException', onUncaught);\n if (onRejection) process.removeListener('unhandledRejection', onRejection);\n onUncaught = null;\n onRejection = null;\n config = null;\n staticContext = {};\n },\n\n // ─── Release Health ───────────────────────────────────────────────────\n\n /** Manually end the current session (status='exited'). */\n endSession(): void {\n _endSession();\n },\n\n /** UUID of the current open session (for diagnostics). */\n getSessionId(): string | null {\n return getCurrentSessionId();\n },\n};\n\n/**\n * Express / Connect / NestJS error middleware. Reports the error then passes\n * it down the chain. Mount it AFTER your routes:\n *\n * import { Pionne, expressErrorHandler } from '@pionne/node';\n * app.use(expressErrorHandler);\n * // your fallback error handler here\n */\nexport function expressErrorHandler(\n err: unknown,\n _req: unknown,\n _res: unknown,\n next: (err?: unknown) => void,\n): void {\n Pionne.captureException(err);\n next(err);\n}\n","// Mirror of @pionne/react-native and @pionne/web security guards.\n// Node-specific tweak: the \"localhost\" rule for non-HTTPS endpoints\n// also accepts hosts ending in `.local` (mDNS) — useful for staging\n// boxes behind Tailscale/ZeroTier.\n\nconst TOKEN_PREFIX = 'pio_live_';\nconst MIN_TOKEN_LENGTH = TOKEN_PREFIX.length + 16;\n\nexport function validateEndpoint(endpoint: string, isDev: boolean): boolean {\n try {\n const u = new URL(endpoint);\n if (u.protocol === 'https:') return true;\n if (u.protocol !== 'http:') return false;\n if (!isDev) return false;\n return /^(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|\\[::1\\]|.*\\.local)$/.test(u.hostname);\n } catch {\n return false;\n }\n}\n\nexport function validateToken(token: string): boolean {\n if (typeof token !== 'string') return false;\n if (!token.startsWith(TOKEN_PREFIX)) return false;\n if (token.length < MIN_TOKEN_LENGTH) return false;\n const lower = token.toLowerCase();\n for (const bad of ['xxx', 'yyy', 'todo', 'fixme', 'replace', 'changeme']) {\n if (lower.includes(bad)) return false;\n }\n return true;\n}\n\nexport class RateLimiter {\n private tokens: number;\n private lastRefill: number;\n constructor(\n private capacity: number,\n private refillPerSecond: number,\n ) {\n this.tokens = capacity;\n this.lastRefill = Date.now();\n }\n allow(): boolean {\n if (this.refillPerSecond <= 0) return true;\n const now = Date.now();\n const elapsedMs = now - this.lastRefill;\n if (elapsedMs > 0) {\n const refill = (elapsedMs / 1000) * this.refillPerSecond;\n this.tokens = Math.min(this.capacity, this.tokens + refill);\n this.lastRefill = now;\n }\n if (this.tokens >= 1) {\n this.tokens -= 1;\n return true;\n }\n return false;\n }\n}\n","// Release Health for Node.js. Same protocol as the browser/RN SDKs.\n// We auto-flip on uncaughtException/unhandledRejection through the host\n// SDK's existing handlers, and best-effort-emit 'exited' on process.exit.\n\nimport * as crypto from 'node:crypto';\n\nexport type SessionStatus = 'ok' | 'crashed' | 'errored' | 'abnormal' | 'exited';\n\nexport interface SessionContext {\n endpoint: string;\n token: string;\n release?: string;\n environment?: string;\n appVersion?: string;\n osName?: string;\n userIdAnon?: string;\n}\n\ninterface SessionState {\n id: string;\n startedAt: number;\n status: SessionStatus;\n ctx: SessionContext;\n}\n\nlet current: SessionState | null = null;\n\nfunction sessionsUrl(ingestEndpoint: string): string {\n if (ingestEndpoint.endsWith('/ingest')) {\n return ingestEndpoint.slice(0, -'/ingest'.length) + '/sessions';\n }\n return ingestEndpoint.replace(/\\/+$/, '') + '/sessions';\n}\n\nfunction postSession(state: SessionState, status: SessionStatus, durationMs?: number): void {\n const url = sessionsUrl(state.ctx.endpoint);\n const body = {\n session_id: state.id,\n status,\n release: state.ctx.release,\n environment: state.ctx.environment,\n app_version: state.ctx.appVersion,\n os_name: state.ctx.osName,\n user_id_anon: state.ctx.userIdAnon,\n duration_ms: durationMs,\n };\n for (const k of Object.keys(body) as (keyof typeof body)[]) {\n if (body[k] === undefined) delete body[k];\n }\n // Node 18+ has global fetch.\n fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'X-Pionne-Token': state.ctx.token,\n },\n body: JSON.stringify(body),\n }).catch(() => undefined);\n}\n\nexport function startSession(ctx: SessionContext): string {\n current = {\n id: crypto.randomUUID(),\n startedAt: Date.now(),\n status: 'ok',\n ctx,\n };\n postSession(current, 'ok');\n\n // Best-effort 'exited' flip on graceful shutdown. We listen on 'exit'\n // (sync only — no async I/O guaranteed) and 'beforeExit' (where async\n // works). The actual session POST is fire-and-forget anyway.\n const onShutdown = () => {\n if (!current || current.status !== 'ok') return;\n flipSession('exited');\n };\n process.once('beforeExit', onShutdown);\n\n return current.id;\n}\n\nexport function flipSession(status: SessionStatus): void {\n if (!current) return;\n const rank: Record<SessionStatus, number> =\n { ok: 0, exited: 1, errored: 2, abnormal: 3, crashed: 4 };\n if (rank[status] <= rank[current.status]) return;\n current.status = status;\n postSession(current, status, Date.now() - current.startedAt);\n}\n\nexport function endSession(status: SessionStatus = 'exited'): void {\n if (!current) return;\n flipSession(status);\n current = null;\n}\n\nexport function getCurrentSessionId(): string | null {\n return current?.id ?? null;\n}\n\nexport function flipFromEvent(\n level: 'fatal' | 'error' | 'warning' | 'info' | undefined,\n mechanismType: string,\n): void {\n if (mechanismType === 'manual') return;\n if (level === 'fatal') flipSession('crashed');\n else if (level === 'error') flipSession('errored');\n}\n"],"mappings":"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","names":["process"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pionne/node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Error monitoring SDK for Node.js
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Error monitoring SDK for Node.js \u2014 Pionne. Auto-captures uncaught exceptions and unhandled rejections, ships runtime context (Node, OS, hostname, pid), and tracks Release Health (crash-free user rate per release).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AGKG Creations",
|
|
7
7
|
"homepage": "https://pionne.agkgcreations.fr",
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import * as os from 'node:os';
|
|
2
2
|
import * as process from 'node:process';
|
|
3
3
|
|
|
4
|
+
import { RateLimiter, validateEndpoint, validateToken } from './security';
|
|
5
|
+
import {
|
|
6
|
+
endSession as _endSession,
|
|
7
|
+
flipFromEvent,
|
|
8
|
+
getCurrentSessionId,
|
|
9
|
+
startSession as _startSession,
|
|
10
|
+
} from './sessions';
|
|
11
|
+
|
|
4
12
|
export type Level = 'fatal' | 'error' | 'warning' | 'info';
|
|
5
13
|
export type MechanismType =
|
|
6
14
|
| 'uncaughtException'
|
|
@@ -46,6 +54,14 @@ export interface PionneOptions {
|
|
|
46
54
|
userIdAnon?: string;
|
|
47
55
|
tags?: Record<string, string>;
|
|
48
56
|
maxStackFrames?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Release Health — opens a session at init() with status='ok', flips to
|
|
59
|
+
* 'crashed'/'errored' if a fatal/error fires through the global handlers.
|
|
60
|
+
* The dashboard derives crash-free user rate per release. Default: true.
|
|
61
|
+
*/
|
|
62
|
+
releaseHealth?: boolean;
|
|
63
|
+
/** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
|
|
64
|
+
maxEventsPerSecond?: number;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
const DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';
|
|
@@ -54,7 +70,7 @@ const SDK_NAME = 'pionne.node';
|
|
|
54
70
|
const SDK_VERSION = '0.1.0';
|
|
55
71
|
|
|
56
72
|
type ResolvedConfig = Required<
|
|
57
|
-
Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release'>
|
|
73
|
+
Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release' | 'releaseHealth' | 'maxEventsPerSecond'>
|
|
58
74
|
> & {
|
|
59
75
|
beforeSend?: PionneOptions['beforeSend'];
|
|
60
76
|
userIdAnon?: string;
|
|
@@ -63,6 +79,8 @@ type ResolvedConfig = Required<
|
|
|
63
79
|
};
|
|
64
80
|
|
|
65
81
|
let config: ResolvedConfig | null = null;
|
|
82
|
+
let rateLimiter: RateLimiter | null = null;
|
|
83
|
+
let droppedByRateLimit = 0;
|
|
66
84
|
let staticContext: Partial<PionneEvent> = {};
|
|
67
85
|
let onUncaught: ((err: Error) => void) | null = null;
|
|
68
86
|
let onRejection: ((reason: unknown) => void) | null = null;
|
|
@@ -143,6 +161,14 @@ function buildEvent(
|
|
|
143
161
|
}
|
|
144
162
|
|
|
145
163
|
async function send(event: PionneEvent): Promise<void> {
|
|
164
|
+
if (rateLimiter && !rateLimiter.allow()) {
|
|
165
|
+
droppedByRateLimit++;
|
|
166
|
+
if (process.env.NODE_ENV !== 'production' && droppedByRateLimit % 50 === 1) {
|
|
167
|
+
console.warn(`[Pionne] rate-limit reached (${droppedByRateLimit} events dropped). Bump maxEventsPerSecond if intentional.`);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
if (!config) return;
|
|
147
173
|
try {
|
|
148
174
|
// Node >=18 has global `fetch`. We rely on it instead of pulling in
|
|
@@ -168,6 +194,7 @@ function installUncaughtHandler(): void {
|
|
|
168
194
|
// Fire-and-forget: process is going to die anyway. Best we can do is
|
|
169
195
|
// try to flush before exit, but Node will tear down imminently.
|
|
170
196
|
void send(event);
|
|
197
|
+
flipFromEvent(event.level, event.mechanism?.type ?? 'uncaughtException');
|
|
171
198
|
}
|
|
172
199
|
};
|
|
173
200
|
process.on('uncaughtException', onUncaught);
|
|
@@ -177,21 +204,31 @@ function installRejectionHandler(): void {
|
|
|
177
204
|
onRejection = (reason: unknown) => {
|
|
178
205
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
179
206
|
const event = buildEvent(err, 'error', 'unhandledRejection', false);
|
|
180
|
-
if (event)
|
|
207
|
+
if (event) {
|
|
208
|
+
void send(event);
|
|
209
|
+
flipFromEvent(event.level, event.mechanism?.type ?? 'unhandledRejection');
|
|
210
|
+
}
|
|
181
211
|
};
|
|
182
212
|
process.on('unhandledRejection', onRejection);
|
|
183
213
|
}
|
|
184
214
|
|
|
185
215
|
export const Pionne = {
|
|
186
216
|
init(options: PionneOptions): void {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
217
|
+
try {
|
|
218
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
219
|
+
if (!options?.token || !validateToken(options.token)) {
|
|
220
|
+
if (isDev) {
|
|
221
|
+
console.warn('[Pionne] Missing or invalid token (expected pio_live_<≥16 chars>, no placeholders).');
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
192
224
|
}
|
|
193
|
-
|
|
194
|
-
|
|
225
|
+
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
|
226
|
+
if (!validateEndpoint(endpoint, isDev)) {
|
|
227
|
+
console.warn('[Pionne] Refusing non-HTTPS endpoint in production:', endpoint);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const rps = options.maxEventsPerSecond ?? 10;
|
|
231
|
+
rateLimiter = rps > 0 ? new RateLimiter(rps, rps) : null;
|
|
195
232
|
|
|
196
233
|
const autoContext = options.autoContext ?? true;
|
|
197
234
|
staticContext = autoContext ? gatherStaticContext() : {};
|
|
@@ -214,6 +251,23 @@ export const Pionne = {
|
|
|
214
251
|
|
|
215
252
|
if (config.captureUncaughtExceptions) installUncaughtHandler();
|
|
216
253
|
if (config.captureUnhandledRejections) installRejectionHandler();
|
|
254
|
+
|
|
255
|
+
// Release Health — open a session unless the host opted out.
|
|
256
|
+
if (options.releaseHealth !== false) {
|
|
257
|
+
_startSession({
|
|
258
|
+
endpoint: config.endpoint,
|
|
259
|
+
token: config.token,
|
|
260
|
+
release: config.release,
|
|
261
|
+
environment: config.environment,
|
|
262
|
+
appVersion: staticContext.app_version,
|
|
263
|
+
osName: staticContext.os_name,
|
|
264
|
+
userIdAnon: config.userIdAnon,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.warn('[Pionne] init failed silently — monitoring disabled.', e);
|
|
269
|
+
config = null;
|
|
270
|
+
}
|
|
217
271
|
},
|
|
218
272
|
|
|
219
273
|
captureException(err: unknown, extra?: Partial<PionneEvent>): void {
|
|
@@ -265,6 +319,18 @@ export const Pionne = {
|
|
|
265
319
|
config = null;
|
|
266
320
|
staticContext = {};
|
|
267
321
|
},
|
|
322
|
+
|
|
323
|
+
// ─── Release Health ───────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/** Manually end the current session (status='exited'). */
|
|
326
|
+
endSession(): void {
|
|
327
|
+
_endSession();
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
/** UUID of the current open session (for diagnostics). */
|
|
331
|
+
getSessionId(): string | null {
|
|
332
|
+
return getCurrentSessionId();
|
|
333
|
+
},
|
|
268
334
|
};
|
|
269
335
|
|
|
270
336
|
/**
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Mirror of @pionne/react-native and @pionne/web security guards.
|
|
2
|
+
// Node-specific tweak: the "localhost" rule for non-HTTPS endpoints
|
|
3
|
+
// also accepts hosts ending in `.local` (mDNS) — useful for staging
|
|
4
|
+
// boxes behind Tailscale/ZeroTier.
|
|
5
|
+
|
|
6
|
+
const TOKEN_PREFIX = 'pio_live_';
|
|
7
|
+
const MIN_TOKEN_LENGTH = TOKEN_PREFIX.length + 16;
|
|
8
|
+
|
|
9
|
+
export function validateEndpoint(endpoint: string, isDev: boolean): boolean {
|
|
10
|
+
try {
|
|
11
|
+
const u = new URL(endpoint);
|
|
12
|
+
if (u.protocol === 'https:') return true;
|
|
13
|
+
if (u.protocol !== 'http:') return false;
|
|
14
|
+
if (!isDev) return false;
|
|
15
|
+
return /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|.*\.local)$/.test(u.hostname);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateToken(token: string): boolean {
|
|
22
|
+
if (typeof token !== 'string') return false;
|
|
23
|
+
if (!token.startsWith(TOKEN_PREFIX)) return false;
|
|
24
|
+
if (token.length < MIN_TOKEN_LENGTH) return false;
|
|
25
|
+
const lower = token.toLowerCase();
|
|
26
|
+
for (const bad of ['xxx', 'yyy', 'todo', 'fixme', 'replace', 'changeme']) {
|
|
27
|
+
if (lower.includes(bad)) return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RateLimiter {
|
|
33
|
+
private tokens: number;
|
|
34
|
+
private lastRefill: number;
|
|
35
|
+
constructor(
|
|
36
|
+
private capacity: number,
|
|
37
|
+
private refillPerSecond: number,
|
|
38
|
+
) {
|
|
39
|
+
this.tokens = capacity;
|
|
40
|
+
this.lastRefill = Date.now();
|
|
41
|
+
}
|
|
42
|
+
allow(): boolean {
|
|
43
|
+
if (this.refillPerSecond <= 0) return true;
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const elapsedMs = now - this.lastRefill;
|
|
46
|
+
if (elapsedMs > 0) {
|
|
47
|
+
const refill = (elapsedMs / 1000) * this.refillPerSecond;
|
|
48
|
+
this.tokens = Math.min(this.capacity, this.tokens + refill);
|
|
49
|
+
this.lastRefill = now;
|
|
50
|
+
}
|
|
51
|
+
if (this.tokens >= 1) {
|
|
52
|
+
this.tokens -= 1;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Release Health for Node.js. Same protocol as the browser/RN SDKs.
|
|
2
|
+
// We auto-flip on uncaughtException/unhandledRejection through the host
|
|
3
|
+
// SDK's existing handlers, and best-effort-emit 'exited' on process.exit.
|
|
4
|
+
|
|
5
|
+
import * as crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export type SessionStatus = 'ok' | 'crashed' | 'errored' | 'abnormal' | 'exited';
|
|
8
|
+
|
|
9
|
+
export interface SessionContext {
|
|
10
|
+
endpoint: string;
|
|
11
|
+
token: string;
|
|
12
|
+
release?: string;
|
|
13
|
+
environment?: string;
|
|
14
|
+
appVersion?: string;
|
|
15
|
+
osName?: string;
|
|
16
|
+
userIdAnon?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SessionState {
|
|
20
|
+
id: string;
|
|
21
|
+
startedAt: number;
|
|
22
|
+
status: SessionStatus;
|
|
23
|
+
ctx: SessionContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let current: SessionState | null = null;
|
|
27
|
+
|
|
28
|
+
function sessionsUrl(ingestEndpoint: string): string {
|
|
29
|
+
if (ingestEndpoint.endsWith('/ingest')) {
|
|
30
|
+
return ingestEndpoint.slice(0, -'/ingest'.length) + '/sessions';
|
|
31
|
+
}
|
|
32
|
+
return ingestEndpoint.replace(/\/+$/, '') + '/sessions';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function postSession(state: SessionState, status: SessionStatus, durationMs?: number): void {
|
|
36
|
+
const url = sessionsUrl(state.ctx.endpoint);
|
|
37
|
+
const body = {
|
|
38
|
+
session_id: state.id,
|
|
39
|
+
status,
|
|
40
|
+
release: state.ctx.release,
|
|
41
|
+
environment: state.ctx.environment,
|
|
42
|
+
app_version: state.ctx.appVersion,
|
|
43
|
+
os_name: state.ctx.osName,
|
|
44
|
+
user_id_anon: state.ctx.userIdAnon,
|
|
45
|
+
duration_ms: durationMs,
|
|
46
|
+
};
|
|
47
|
+
for (const k of Object.keys(body) as (keyof typeof body)[]) {
|
|
48
|
+
if (body[k] === undefined) delete body[k];
|
|
49
|
+
}
|
|
50
|
+
// Node 18+ has global fetch.
|
|
51
|
+
fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'X-Pionne-Token': state.ctx.token,
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
}).catch(() => undefined);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function startSession(ctx: SessionContext): string {
|
|
62
|
+
current = {
|
|
63
|
+
id: crypto.randomUUID(),
|
|
64
|
+
startedAt: Date.now(),
|
|
65
|
+
status: 'ok',
|
|
66
|
+
ctx,
|
|
67
|
+
};
|
|
68
|
+
postSession(current, 'ok');
|
|
69
|
+
|
|
70
|
+
// Best-effort 'exited' flip on graceful shutdown. We listen on 'exit'
|
|
71
|
+
// (sync only — no async I/O guaranteed) and 'beforeExit' (where async
|
|
72
|
+
// works). The actual session POST is fire-and-forget anyway.
|
|
73
|
+
const onShutdown = () => {
|
|
74
|
+
if (!current || current.status !== 'ok') return;
|
|
75
|
+
flipSession('exited');
|
|
76
|
+
};
|
|
77
|
+
process.once('beforeExit', onShutdown);
|
|
78
|
+
|
|
79
|
+
return current.id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function flipSession(status: SessionStatus): void {
|
|
83
|
+
if (!current) return;
|
|
84
|
+
const rank: Record<SessionStatus, number> =
|
|
85
|
+
{ ok: 0, exited: 1, errored: 2, abnormal: 3, crashed: 4 };
|
|
86
|
+
if (rank[status] <= rank[current.status]) return;
|
|
87
|
+
current.status = status;
|
|
88
|
+
postSession(current, status, Date.now() - current.startedAt);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function endSession(status: SessionStatus = 'exited'): void {
|
|
92
|
+
if (!current) return;
|
|
93
|
+
flipSession(status);
|
|
94
|
+
current = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getCurrentSessionId(): string | null {
|
|
98
|
+
return current?.id ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function flipFromEvent(
|
|
102
|
+
level: 'fatal' | 'error' | 'warning' | 'info' | undefined,
|
|
103
|
+
mechanismType: string,
|
|
104
|
+
): void {
|
|
105
|
+
if (mechanismType === 'manual') return;
|
|
106
|
+
if (level === 'fatal') flipSession('crashed');
|
|
107
|
+
else if (level === 'error') flipSession('errored');
|
|
108
|
+
}
|