@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 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 process = __toESM(require("process"));
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: process.versions.node,
62
- v8: process.versions.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: process.platform,
68
- arch: process.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: process.pid,
76
- cwd: process.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
- process.on("uncaughtException", onUncaught);
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) void send(event);
265
+ if (event) {
266
+ void send(event);
267
+ flipFromEvent(event.level, event.mechanism?.type ?? "unhandledRejection");
268
+ }
137
269
  };
138
- process.on("unhandledRejection", onRejection);
270
+ process2.on("unhandledRejection", onRejection);
139
271
  }
140
272
  var Pionne = {
141
273
  init(options) {
142
- if (!options?.token || !options.token.startsWith("pio_live_")) {
143
- if (process.env.NODE_ENV !== "production") {
144
- console.warn(
145
- "[Pionne] Missing or invalid token (must start with pio_live_)."
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
- return;
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) process.removeListener("uncaughtException", onUncaught);
207
- if (onRejection) process.removeListener("unhandledRejection", 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) {
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,SAAoB;AACpB,IAAAA,WAAyB;;;ACIzB,IAAM,eAAe;AACrB,IAAM,mBAAmB,aAAa,SAAS;AAExC,SAAS,iBAAiB,UAAkB,OAAyB;AAC1E,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,QAAQ;AAC1B,QAAI,EAAE,aAAa,SAAU,QAAO;AACpC,QAAI,EAAE,aAAa,QAAS,QAAO;AACnC,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO,0DAA0D,KAAK,EAAE,QAAQ;AAAA,EAClF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,cAAc,OAAwB;AACpD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,CAAC,MAAM,WAAW,YAAY,EAAG,QAAO;AAC5C,MAAI,MAAM,SAAS,iBAAkB,QAAO;AAC5C,QAAM,QAAQ,MAAM,YAAY;AAChC,aAAW,OAAO,CAAC,OAAO,OAAO,QAAQ,SAAS,WAAW,UAAU,GAAG;AACxE,QAAI,MAAM,SAAS,GAAG,EAAG,QAAO;AAAA,EAClC;AACA,SAAO;AACT;AAEO,IAAM,cAAN,MAAkB;AAAA,EAGvB,YACU,UACA,iBACR;AAFQ;AACA;AAER,SAAK,SAAS;AACd,SAAK,aAAa,KAAK,IAAI;AAAA,EAC7B;AAAA,EACA,QAAiB;AACf,QAAI,KAAK,mBAAmB,EAAG,QAAO;AACtC,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,YAAY,MAAM,KAAK;AAC7B,QAAI,YAAY,GAAG;AACjB,YAAM,SAAU,YAAY,MAAQ,KAAK;AACzC,WAAK,SAAS,KAAK,IAAI,KAAK,UAAU,KAAK,SAAS,MAAM;AAC1D,WAAK,aAAa;AAAA,IACpB;AACA,QAAI,KAAK,UAAU,GAAG;AACpB,WAAK,UAAU;AACf,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AACF;;;ACpDA,aAAwB;AAqBxB,IAAI,UAA+B;AAEnC,SAAS,YAAY,gBAAgC;AACnD,MAAI,eAAe,SAAS,SAAS,GAAG;AACtC,WAAO,eAAe,MAAM,GAAG,CAAC,UAAU,MAAM,IAAI;AAAA,EACtD;AACA,SAAO,eAAe,QAAQ,QAAQ,EAAE,IAAI;AAC9C;AAEA,SAAS,YAAY,OAAqB,QAAuB,YAA2B;AAC1F,QAAM,MAAM,YAAY,MAAM,IAAI,QAAQ;AAC1C,QAAM,OAAO;AAAA,IACX,YAAY,MAAM;AAAA,IAClB;AAAA,IACA,SAAS,MAAM,IAAI;AAAA,IACnB,aAAa,MAAM,IAAI;AAAA,IACvB,aAAa,MAAM,IAAI;AAAA,IACvB,SAAS,MAAM,IAAI;AAAA,IACnB,cAAc,MAAM,IAAI;AAAA,IACxB,aAAa;AAAA,EACf;AACA,aAAW,KAAK,OAAO,KAAK,IAAI,GAA4B;AAC1D,QAAI,KAAK,CAAC,MAAM,OAAW,QAAO,KAAK,CAAC;AAAA,EAC1C;AAEA,QAAM,KAAK;AAAA,IACT,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,kBAAkB,MAAM,IAAI;AAAA,IAC9B;AAAA,IACA,MAAM,KAAK,UAAU,IAAI;AAAA,EAC3B,CAAC,EAAE,MAAM,MAAM,MAAS;AAC1B;AAEO,SAAS,aAAa,KAA6B;AACxD,YAAU;AAAA,IACR,IAAW,kBAAW;AAAA,IACtB,WAAW,KAAK,IAAI;AAAA,IACpB,QAAQ;AAAA,IACR;AAAA,EACF;AACA,cAAY,SAAS,IAAI;AAKzB,QAAM,aAAa,MAAM;AACvB,QAAI,CAAC,WAAW,QAAQ,WAAW,KAAM;AACzC,gBAAY,QAAQ;AAAA,EACtB;AACA,UAAQ,KAAK,cAAc,UAAU;AAErC,SAAO,QAAQ;AACjB;AAEO,SAAS,YAAY,QAA6B;AACvD,MAAI,CAAC,QAAS;AACd,QAAM,OACJ,EAAE,IAAI,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,EAAE;AAC1D,MAAI,KAAK,MAAM,KAAK,KAAK,QAAQ,MAAM,EAAG;AAC1C,UAAQ,SAAS;AACjB,cAAY,SAAS,QAAQ,KAAK,IAAI,IAAI,QAAQ,SAAS;AAC7D;AAEO,SAAS,WAAW,SAAwB,UAAgB;AACjE,MAAI,CAAC,QAAS;AACd,cAAY,MAAM;AAClB,YAAU;AACZ;AAEO,SAAS,sBAAqC;AACnD,SAAO,SAAS,MAAM;AACxB;AAEO,SAAS,cACd,OACA,eACM;AACN,MAAI,kBAAkB,SAAU;AAChC,MAAI,UAAU,QAAS,aAAY,SAAS;AAAA,WACnC,UAAU,QAAS,aAAY,SAAS;AACnD;;;AFzCA,IAAM,mBAAmB;AACzB,IAAM,oBAAoB;AAC1B,IAAM,WAAW;AACjB,IAAM,cAAc;AAWpB,IAAI,SAAgC;AACpC,IAAI,cAAkC;AACtC,IAAI,qBAAqB;AACzB,IAAI,gBAAsC,CAAC;AAC3C,IAAI,aAA4C;AAChD,IAAI,cAAkD;AAEtD,SAAS,sBAA4C;AACnD,MAAI;AACJ,MAAI;AACF,eAAW,KAAK,eAAe,EAAE,gBAAgB,EAAE;AAAA,EACrD,QAAQ;AAAA,EAER;AACA,SAAO;AAAA,IACL,SAAY,QAAK;AAAA,IACjB,YAAe,WAAQ;AAAA,IACvB;AAAA,IACA,UAAU;AAAA,MACR,KAAK,EAAE,MAAM,UAAU,SAAS,YAAY;AAAA,MAC5C,SAAS;AAAA,QACP,MAAM;AAAA,QACN,SAAiB,kBAAS;AAAA,QAC1B,IAAY,kBAAS;AAAA,MACvB;AAAA,MACA,IAAI;AAAA,QACF,MAAS,QAAK;AAAA,QACd,SAAY,WAAQ;AAAA,QACpB,UAAkB;AAAA,QAClB,MAAc;AAAA,QACd,WAAc,QAAK,EAAE;AAAA,QACrB,cAAiB,YAAS;AAAA,QAC1B,aAAgB,WAAQ;AAAA,MAC1B;AAAA,MACA,KAAK;AAAA,QACH,UAAa,YAAS;AAAA,QACtB,KAAa;AAAA,QACb,KAAa,aAAI;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,WAAW,OAAc,KAAuB;AACvD,MAAI,CAAC,MAAM,MAAO,QAAO,CAAC;AAC1B,SAAO,MAAM,MACV,MAAM,IAAI,EACV,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AACnB;AAEA,SAAS,WACP,KACA,OACA,WACA,SACA,OACoB;AACpB,MAAI,CAAC,UAAU,CAAC,OAAO,QAAS,QAAO;AACvC,QAAM,IAAI,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AAC5D,QAAM,QAAqB;AAAA,IACzB,GAAG;AAAA,IACH,gBAAgB,EAAE,QAAQ;AAAA,IAC1B,SAAS,EAAE,WAAW;AAAA,IACtB,OAAO,WAAW,GAAG,OAAO,cAAc;AAAA,IAC1C;AAAA,IACA,SAAS,OAAO;AAAA,IAChB,aAAa,OAAO;AAAA,IACpB,cAAc,OAAO;AAAA,IACrB,MAAM,OAAO;AAAA,IACb,WAAW,EAAE,MAAM,WAAW,QAAQ;AAAA,IACtC,GAAG;AAAA,EACL;AACA,MAAI,OAAO,YAAY;AACrB,UAAM,SAAS,OAAO,WAAW,KAAK;AACtC,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,eAAe,KAAK,OAAmC;AACrD,MAAI,eAAe,CAAC,YAAY,MAAM,GAAG;AACvC;AACA,QAAY,aAAI,aAAa,gBAAgB,qBAAqB,OAAO,GAAG;AAC1E,cAAQ,KAAK,gCAAgC,kBAAkB,2DAA2D;AAAA,IAC5H;AACA;AAAA,EACF;AAEA,MAAI,CAAC,OAAQ;AACb,MAAI;AAGF,QAAI,OAAO,UAAU,WAAY;AACjC,UAAM,MAAM,OAAO,UAAU;AAAA,MAC3B,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,kBAAkB,OAAO;AAAA,MAC3B;AAAA,MACA,MAAM,KAAK,UAAU,KAAK;AAAA,IAC5B,CAAC;AAAA,EACH,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,yBAA+B;AACtC,eAAa,CAAC,QAAe;AAC3B,UAAM,QAAQ,WAAW,KAAK,SAAS,qBAAqB,KAAK;AACjE,QAAI,OAAO;AAGT,WAAK,KAAK,KAAK;AACf,oBAAc,MAAM,OAAO,MAAM,WAAW,QAAQ,mBAAmB;AAAA,IACzE;AAAA,EACF;AACA,EAAQ,YAAG,qBAAqB,UAAU;AAC5C;AAEA,SAAS,0BAAgC;AACvC,gBAAc,CAAC,WAAoB;AACjC,UAAM,MAAM,kBAAkB,QAAQ,SAAS,IAAI,MAAM,OAAO,MAAM,CAAC;AACvE,UAAM,QAAQ,WAAW,KAAK,SAAS,sBAAsB,KAAK;AAClE,QAAI,OAAO;AACT,WAAK,KAAK,KAAK;AACf,oBAAc,MAAM,OAAO,MAAM,WAAW,QAAQ,oBAAoB;AAAA,IAC1E;AAAA,EACF;AACA,EAAQ,YAAG,sBAAsB,WAAW;AAC9C;AAEO,IAAM,SAAS;AAAA,EACpB,KAAK,SAA8B;AACjC,QAAI;AACF,YAAM,QAAgB,aAAI,aAAa;AACvC,UAAI,CAAC,SAAS,SAAS,CAAC,cAAc,QAAQ,KAAK,GAAG;AACpD,YAAI,OAAO;AACT,kBAAQ,KAAK,0FAAqF;AAAA,QACpG;AACA;AAAA,MACF;AACA,YAAM,WAAW,QAAQ,YAAY;AACrC,UAAI,CAAC,iBAAiB,UAAU,KAAK,GAAG;AACtC,gBAAQ,KAAK,uDAAuD,QAAQ;AAC5E;AAAA,MACF;AACA,YAAM,MAAM,QAAQ,sBAAsB;AAC1C,oBAAc,MAAM,IAAI,IAAI,YAAY,KAAK,GAAG,IAAI;AAEtD,YAAM,cAAc,QAAQ,eAAe;AAC3C,sBAAgB,cAAc,oBAAoB,IAAI,CAAC;AAEvD,eAAS;AAAA,QACP,OAAO,QAAQ;AAAA,QACf,UAAU,QAAQ,YAAY;AAAA,QAC9B,SAAS,QAAQ;AAAA,QACjB,aACE,QAAQ,eAAuB,aAAI,YAAY;AAAA,QACjD,SAAS,QAAQ,WAAW;AAAA,QAC5B,2BAA2B,QAAQ,6BAA6B;AAAA,QAChE,4BAA4B,QAAQ,8BAA8B;AAAA,QAClE;AAAA,QACA,YAAY,QAAQ;AAAA,QACpB,YAAY,QAAQ;AAAA,QACpB,MAAM,QAAQ;AAAA,QACd,gBAAgB,QAAQ,kBAAkB;AAAA,MAC5C;AAEA,UAAI,OAAO,0BAA2B,wBAAuB;AAC7D,UAAI,OAAO,2BAA4B,yBAAwB;AAG/D,UAAI,QAAQ,kBAAkB,OAAO;AACnC,qBAAc;AAAA,UACZ,UAAU,OAAO;AAAA,UACjB,OAAO,OAAO;AAAA,UACd,SAAS,OAAO;AAAA,UAChB,aAAa,OAAO;AAAA,UACpB,YAAY,cAAc;AAAA,UAC1B,QAAQ,cAAc;AAAA,UACtB,YAAY,OAAO;AAAA,QACrB,CAAC;AAAA,MACH;AAAA,IACA,SAAS,GAAG;AACV,cAAQ,KAAK,6DAAwD,CAAC;AACtE,eAAS;AAAA,IACX;AAAA,EACF;AAAA,EAEA,iBAAiB,KAAc,OAAoC;AACjE,UAAM,QAAQ;AAAA,MACZ;AAAA,MACA,OAAO,SAAS;AAAA,MAChB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,MAAO,MAAK,KAAK,KAAK;AAAA,EAC5B;AAAA,EAEA,eAAe,SAAiB,OAAoC;AAClE,UAAM,QAAQ;AAAA,MACZ,IAAI,MAAM,OAAO;AAAA,MACjB,OAAO,SAAS;AAAA,MAChB;AAAA,MACA;AAAA,MACA,EAAE,gBAAgB,WAAW,GAAG,MAAM;AAAA,IACxC;AACA,QAAI,MAAO,MAAK,KAAK,KAAK;AAAA,EAC5B;AAAA,EAEA,QAAQ,YAAiC;AACvC,QAAI,CAAC,OAAQ;AACb,WAAO,aAAa,cAAc;AAAA,EACpC;AAAA,EAEA,QAAQ,MAA2C;AACjD,QAAI,CAAC,OAAQ;AACb,WAAO,OAAO,QAAQ;AAAA,EACxB;AAAA,EAEA,WAAW,SAAwB;AACjC,QAAI,CAAC,OAAQ;AACb,WAAO,UAAU;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAkB;AAChB,QAAI,WAAY,CAAQ,wBAAe,qBAAqB,UAAU;AACtE,QAAI,YAAa,CAAQ,wBAAe,sBAAsB,WAAW;AACzE,iBAAa;AACb,kBAAc;AACd,aAAS;AACT,oBAAgB,CAAC;AAAA,EACnB;AAAA;AAAA;AAAA,EAKA,aAAmB;AACjB,eAAY;AAAA,EACd;AAAA;AAAA,EAGA,eAA8B;AAC5B,WAAO,oBAAoB;AAAA,EAC7B;AACF;AAUO,SAAS,oBACd,KACA,MACA,MACA,MACM;AACN,SAAO,iBAAiB,GAAG;AAC3B,OAAK,GAAG;AACV;","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 process from "process";
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: process.versions.node,
27
- v8: process.versions.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: process.platform,
33
- arch: process.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: process.pid,
41
- cwd: process.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
- process.on("uncaughtException", onUncaught);
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) void send(event);
230
+ if (event) {
231
+ void send(event);
232
+ flipFromEvent(event.level, event.mechanism?.type ?? "unhandledRejection");
233
+ }
102
234
  };
103
- process.on("unhandledRejection", onRejection);
235
+ process2.on("unhandledRejection", onRejection);
104
236
  }
105
237
  var Pionne = {
106
238
  init(options) {
107
- if (!options?.token || !options.token.startsWith("pio_live_")) {
108
- if (process.env.NODE_ENV !== "production") {
109
- console.warn(
110
- "[Pionne] Missing or invalid token (must start with pio_live_)."
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
- return;
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) process.removeListener("uncaughtException", onUncaught);
172
- if (onRejection) process.removeListener("unhandledRejection", 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) {
@@ -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":";AAAA,YAAY,QAAQ;AACpB,YAAY,aAAa;AAiDzB,IAAM,mBAAmB;AACzB,IAAM,oBAAoB;AAC1B,IAAM,WAAW;AACjB,IAAM,cAAc;AAWpB,IAAI,SAAgC;AACpC,IAAI,gBAAsC,CAAC;AAC3C,IAAI,aAA4C;AAChD,IAAI,cAAkD;AAEtD,SAAS,sBAA4C;AACnD,MAAI;AACJ,MAAI;AACF,eAAW,KAAK,eAAe,EAAE,gBAAgB,EAAE;AAAA,EACrD,QAAQ;AAAA,EAER;AACA,SAAO;AAAA,IACL,SAAY,QAAK;AAAA,IACjB,YAAe,WAAQ;AAAA,IACvB;AAAA,IACA,UAAU;AAAA,MACR,KAAK,EAAE,MAAM,UAAU,SAAS,YAAY;AAAA,MAC5C,SAAS;AAAA,QACP,MAAM;AAAA,QACN,SAAiB,iBAAS;AAAA,QAC1B,IAAY,iBAAS;AAAA,MACvB;AAAA,MACA,IAAI;AAAA,QACF,MAAS,QAAK;AAAA,QACd,SAAY,WAAQ;AAAA,QACpB,UAAkB;AAAA,QAClB,MAAc;AAAA,QACd,WAAc,QAAK,EAAE;AAAA,QACrB,cAAiB,YAAS;AAAA,QAC1B,aAAgB,WAAQ;AAAA,MAC1B;AAAA,MACA,KAAK;AAAA,QACH,UAAa,YAAS;AAAA,QACtB,KAAa;AAAA,QACb,KAAa,YAAI;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,WAAW,OAAc,KAAuB;AACvD,MAAI,CAAC,MAAM,MAAO,QAAO,CAAC;AAC1B,SAAO,MAAM,MACV,MAAM,IAAI,EACV,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AACnB;AAEA,SAAS,WACP,KACA,OACA,WACA,SACA,OACoB;AACpB,MAAI,CAAC,UAAU,CAAC,OAAO,QAAS,QAAO;AACvC,QAAM,IAAI,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC;AAC5D,QAAM,QAAqB;AAAA,IACzB,GAAG;AAAA,IACH,gBAAgB,EAAE,QAAQ;AAAA,IAC1B,SAAS,EAAE,WAAW;AAAA,IACtB,OAAO,WAAW,GAAG,OAAO,cAAc;AAAA,IAC1C;AAAA,IACA,SAAS,OAAO;AAAA,IAChB,aAAa,OAAO;AAAA,IACpB,cAAc,OAAO;AAAA,IACrB,MAAM,OAAO;AAAA,IACb,WAAW,EAAE,MAAM,WAAW,QAAQ;AAAA,IACtC,GAAG;AAAA,EACL;AACA,MAAI,OAAO,YAAY;AACrB,UAAM,SAAS,OAAO,WAAW,KAAK;AACtC,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,eAAe,KAAK,OAAmC;AACrD,MAAI,CAAC,OAAQ;AACb,MAAI;AAGF,QAAI,OAAO,UAAU,WAAY;AACjC,UAAM,MAAM,OAAO,UAAU;AAAA,MAC3B,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,kBAAkB,OAAO;AAAA,MAC3B;AAAA,MACA,MAAM,KAAK,UAAU,KAAK;AAAA,IAC5B,CAAC;AAAA,EACH,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,yBAA+B;AACtC,eAAa,CAAC,QAAe;AAC3B,UAAM,QAAQ,WAAW,KAAK,SAAS,qBAAqB,KAAK;AACjE,QAAI,OAAO;AAGT,WAAK,KAAK,KAAK;AAAA,IACjB;AAAA,EACF;AACA,EAAQ,WAAG,qBAAqB,UAAU;AAC5C;AAEA,SAAS,0BAAgC;AACvC,gBAAc,CAAC,WAAoB;AACjC,UAAM,MAAM,kBAAkB,QAAQ,SAAS,IAAI,MAAM,OAAO,MAAM,CAAC;AACvE,UAAM,QAAQ,WAAW,KAAK,SAAS,sBAAsB,KAAK;AAClE,QAAI,MAAO,MAAK,KAAK,KAAK;AAAA,EAC5B;AACA,EAAQ,WAAG,sBAAsB,WAAW;AAC9C;AAEO,IAAM,SAAS;AAAA,EACpB,KAAK,SAA8B;AACjC,QAAI,CAAC,SAAS,SAAS,CAAC,QAAQ,MAAM,WAAW,WAAW,GAAG;AAC7D,UAAY,YAAI,aAAa,cAAc;AACzC,gBAAQ;AAAA,UACN;AAAA,QACF;AAAA,MACF;AACA;AAAA,IACF;AAEA,UAAM,cAAc,QAAQ,eAAe;AAC3C,oBAAgB,cAAc,oBAAoB,IAAI,CAAC;AAEvD,aAAS;AAAA,MACP,OAAO,QAAQ;AAAA,MACf,UAAU,QAAQ,YAAY;AAAA,MAC9B,SAAS,QAAQ;AAAA,MACjB,aACE,QAAQ,eAAuB,YAAI,YAAY;AAAA,MACjD,SAAS,QAAQ,WAAW;AAAA,MAC5B,2BAA2B,QAAQ,6BAA6B;AAAA,MAChE,4BAA4B,QAAQ,8BAA8B;AAAA,MAClE;AAAA,MACA,YAAY,QAAQ;AAAA,MACpB,YAAY,QAAQ;AAAA,MACpB,MAAM,QAAQ;AAAA,MACd,gBAAgB,QAAQ,kBAAkB;AAAA,IAC5C;AAEA,QAAI,OAAO,0BAA2B,wBAAuB;AAC7D,QAAI,OAAO,2BAA4B,yBAAwB;AAAA,EACjE;AAAA,EAEA,iBAAiB,KAAc,OAAoC;AACjE,UAAM,QAAQ;AAAA,MACZ;AAAA,MACA,OAAO,SAAS;AAAA,MAChB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,MAAO,MAAK,KAAK,KAAK;AAAA,EAC5B;AAAA,EAEA,eAAe,SAAiB,OAAoC;AAClE,UAAM,QAAQ;AAAA,MACZ,IAAI,MAAM,OAAO;AAAA,MACjB,OAAO,SAAS;AAAA,MAChB;AAAA,MACA;AAAA,MACA,EAAE,gBAAgB,WAAW,GAAG,MAAM;AAAA,IACxC;AACA,QAAI,MAAO,MAAK,KAAK,KAAK;AAAA,EAC5B;AAAA,EAEA,QAAQ,YAAiC;AACvC,QAAI,CAAC,OAAQ;AACb,WAAO,aAAa,cAAc;AAAA,EACpC;AAAA,EAEA,QAAQ,MAA2C;AACjD,QAAI,CAAC,OAAQ;AACb,WAAO,OAAO,QAAQ;AAAA,EACxB;AAAA,EAEA,WAAW,SAAwB;AACjC,QAAI,CAAC,OAAQ;AACb,WAAO,UAAU;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,YAAkB;AAChB,QAAI,WAAY,CAAQ,uBAAe,qBAAqB,UAAU;AACtE,QAAI,YAAa,CAAQ,uBAAe,sBAAsB,WAAW;AACzE,iBAAa;AACb,kBAAc;AACd,aAAS;AACT,oBAAgB,CAAC;AAAA,EACnB;AACF;AAUO,SAAS,oBACd,KACA,MACA,MACA,MACM;AACN,SAAO,iBAAiB,GAAG;AAC3B,OAAK,GAAG;AACV;","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.2.0",
4
- "description": "Error monitoring SDK for Node.js Pionne. Auto-captures uncaught exceptions and unhandled rejections, ships rich runtime context (Node version, OS, hostname, pid).",
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) void send(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
- if (!options?.token || !options.token.startsWith('pio_live_')) {
188
- if (process.env.NODE_ENV !== 'production') {
189
- console.warn(
190
- '[Pionne] Missing or invalid token (must start with pio_live_).',
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
- return;
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
  /**
@@ -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
+ }
@@ -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
+ }