@pionne/node 0.3.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
@@ -37,6 +37,54 @@ module.exports = __toCommonJS(index_exports);
37
37
  var os = __toESM(require("os"));
38
38
  var process2 = __toESM(require("process"));
39
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
+
40
88
  // src/sessions.ts
41
89
  var crypto = __toESM(require("crypto"));
42
90
  var current = null;
@@ -112,6 +160,8 @@ var DEFAULT_MAX_STACK = 50;
112
160
  var SDK_NAME = "pionne.node";
113
161
  var SDK_VERSION = "0.1.0";
114
162
  var config = null;
163
+ var rateLimiter = null;
164
+ var droppedByRateLimit = 0;
115
165
  var staticContext = {};
116
166
  var onUncaught = null;
117
167
  var onRejection = null;
@@ -177,6 +227,13 @@ function buildEvent(err, level, mechanism, handled, extra) {
177
227
  return event;
178
228
  }
179
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
+ }
180
237
  if (!config) return;
181
238
  try {
182
239
  if (typeof fetch !== "function") return;
@@ -214,42 +271,53 @@ function installRejectionHandler() {
214
271
  }
215
272
  var Pionne = {
216
273
  init(options) {
217
- if (!options?.token || !options.token.startsWith("pio_live_")) {
218
- if (process2.env.NODE_ENV !== "production") {
219
- console.warn(
220
- "[Pionne] Missing or invalid token (must start with pio_live_)."
221
- );
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;
222
281
  }
223
- return;
224
- }
225
- const autoContext = options.autoContext ?? true;
226
- staticContext = autoContext ? gatherStaticContext() : {};
227
- config = {
228
- token: options.token,
229
- endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
230
- release: options.release,
231
- environment: options.environment ?? process2.env.NODE_ENV ?? "production",
232
- enabled: options.enabled ?? true,
233
- captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
234
- captureUnhandledRejections: options.captureUnhandledRejections ?? true,
235
- autoContext,
236
- beforeSend: options.beforeSend,
237
- userIdAnon: options.userIdAnon,
238
- tags: options.tags,
239
- maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
240
- };
241
- if (config.captureUncaughtExceptions) installUncaughtHandler();
242
- if (config.captureUnhandledRejections) installRejectionHandler();
243
- if (options.releaseHealth !== false) {
244
- startSession({
245
- endpoint: config.endpoint,
246
- token: config.token,
247
- release: config.release,
248
- environment: config.environment,
249
- appVersion: staticContext.app_version,
250
- osName: staticContext.os_name,
251
- userIdAnon: config.userIdAnon
252
- });
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;
253
321
  }
254
322
  },
255
323
  captureException(err, extra) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/sessions.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\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}\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'>\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 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 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 // 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 },\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","// 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;;;ACGzB,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;;;AD5CA,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,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,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,CAAC,SAAS,SAAS,CAAC,QAAQ,MAAM,WAAW,WAAW,GAAG;AAC7D,UAAY,aAAI,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,aAAI,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;AAG/D,QAAI,QAAQ,kBAAkB,OAAO;AACnC,mBAAc;AAAA,QACZ,UAAU,OAAO;AAAA,QACjB,OAAO,OAAO;AAAA,QACd,SAAS,OAAO;AAAA,QAChB,aAAa,OAAO;AAAA,QACpB,YAAY,cAAc;AAAA,QAC1B,QAAQ,cAAc;AAAA,QACtB,YAAY,OAAO;AAAA,MACrB,CAAC;AAAA,IACH;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"]}
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
@@ -41,6 +41,8 @@ interface PionneOptions {
41
41
  * The dashboard derives crash-free user rate per release. Default: true.
42
42
  */
43
43
  releaseHealth?: boolean;
44
+ /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
45
+ maxEventsPerSecond?: number;
44
46
  }
45
47
  declare const Pionne: {
46
48
  init(options: PionneOptions): void;
package/dist/index.d.ts CHANGED
@@ -41,6 +41,8 @@ interface PionneOptions {
41
41
  * The dashboard derives crash-free user rate per release. Default: true.
42
42
  */
43
43
  releaseHealth?: boolean;
44
+ /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
45
+ maxEventsPerSecond?: number;
44
46
  }
45
47
  declare const Pionne: {
46
48
  init(options: PionneOptions): void;
package/dist/index.mjs CHANGED
@@ -2,6 +2,54 @@
2
2
  import * as os from "os";
3
3
  import * as process2 from "process";
4
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
+
5
53
  // src/sessions.ts
6
54
  import * as crypto from "crypto";
7
55
  var current = null;
@@ -77,6 +125,8 @@ var DEFAULT_MAX_STACK = 50;
77
125
  var SDK_NAME = "pionne.node";
78
126
  var SDK_VERSION = "0.1.0";
79
127
  var config = null;
128
+ var rateLimiter = null;
129
+ var droppedByRateLimit = 0;
80
130
  var staticContext = {};
81
131
  var onUncaught = null;
82
132
  var onRejection = null;
@@ -142,6 +192,13 @@ function buildEvent(err, level, mechanism, handled, extra) {
142
192
  return event;
143
193
  }
144
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
+ }
145
202
  if (!config) return;
146
203
  try {
147
204
  if (typeof fetch !== "function") return;
@@ -179,42 +236,53 @@ function installRejectionHandler() {
179
236
  }
180
237
  var Pionne = {
181
238
  init(options) {
182
- if (!options?.token || !options.token.startsWith("pio_live_")) {
183
- if (process2.env.NODE_ENV !== "production") {
184
- console.warn(
185
- "[Pionne] Missing or invalid token (must start with pio_live_)."
186
- );
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;
187
246
  }
188
- return;
189
- }
190
- const autoContext = options.autoContext ?? true;
191
- staticContext = autoContext ? gatherStaticContext() : {};
192
- config = {
193
- token: options.token,
194
- endpoint: options.endpoint ?? DEFAULT_ENDPOINT,
195
- release: options.release,
196
- environment: options.environment ?? process2.env.NODE_ENV ?? "production",
197
- enabled: options.enabled ?? true,
198
- captureUncaughtExceptions: options.captureUncaughtExceptions ?? true,
199
- captureUnhandledRejections: options.captureUnhandledRejections ?? true,
200
- autoContext,
201
- beforeSend: options.beforeSend,
202
- userIdAnon: options.userIdAnon,
203
- tags: options.tags,
204
- maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
205
- };
206
- if (config.captureUncaughtExceptions) installUncaughtHandler();
207
- if (config.captureUnhandledRejections) installRejectionHandler();
208
- if (options.releaseHealth !== false) {
209
- startSession({
210
- endpoint: config.endpoint,
211
- token: config.token,
212
- release: config.release,
213
- environment: config.environment,
214
- appVersion: staticContext.app_version,
215
- osName: staticContext.os_name,
216
- userIdAnon: config.userIdAnon
217
- });
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;
218
286
  }
219
287
  },
220
288
  captureException(err, extra) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/sessions.ts"],"sourcesContent":["import * as os from 'node:os';\nimport * as process from 'node:process';\n\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}\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'>\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 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 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 // 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 },\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","// 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"]}
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.3.0",
4
- "description": "Error monitoring SDK for Node.js 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).",
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,7 @@
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';
4
5
  import {
5
6
  endSession as _endSession,
6
7
  flipFromEvent,
@@ -59,6 +60,8 @@ export interface PionneOptions {
59
60
  * The dashboard derives crash-free user rate per release. Default: true.
60
61
  */
61
62
  releaseHealth?: boolean;
63
+ /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
64
+ maxEventsPerSecond?: number;
62
65
  }
63
66
 
64
67
  const DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';
@@ -67,7 +70,7 @@ const SDK_NAME = 'pionne.node';
67
70
  const SDK_VERSION = '0.1.0';
68
71
 
69
72
  type ResolvedConfig = Required<
70
- Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release' | 'releaseHealth'>
73
+ Omit<PionneOptions, 'beforeSend' | 'userIdAnon' | 'tags' | 'release' | 'releaseHealth' | 'maxEventsPerSecond'>
71
74
  > & {
72
75
  beforeSend?: PionneOptions['beforeSend'];
73
76
  userIdAnon?: string;
@@ -76,6 +79,8 @@ type ResolvedConfig = Required<
76
79
  };
77
80
 
78
81
  let config: ResolvedConfig | null = null;
82
+ let rateLimiter: RateLimiter | null = null;
83
+ let droppedByRateLimit = 0;
79
84
  let staticContext: Partial<PionneEvent> = {};
80
85
  let onUncaught: ((err: Error) => void) | null = null;
81
86
  let onRejection: ((reason: unknown) => void) | null = null;
@@ -156,6 +161,14 @@ function buildEvent(
156
161
  }
157
162
 
158
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
+
159
172
  if (!config) return;
160
173
  try {
161
174
  // Node >=18 has global `fetch`. We rely on it instead of pulling in
@@ -201,14 +214,21 @@ function installRejectionHandler(): void {
201
214
 
202
215
  export const Pionne = {
203
216
  init(options: PionneOptions): void {
204
- if (!options?.token || !options.token.startsWith('pio_live_')) {
205
- if (process.env.NODE_ENV !== 'production') {
206
- console.warn(
207
- '[Pionne] Missing or invalid token (must start with pio_live_).',
208
- );
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;
209
224
  }
210
- return;
211
- }
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;
212
232
 
213
233
  const autoContext = options.autoContext ?? true;
214
234
  staticContext = autoContext ? gatherStaticContext() : {};
@@ -244,6 +264,10 @@ export const Pionne = {
244
264
  userIdAnon: config.userIdAnon,
245
265
  });
246
266
  }
267
+ } catch (e) {
268
+ console.warn('[Pionne] init failed silently — monitoring disabled.', e);
269
+ config = null;
270
+ }
247
271
  },
248
272
 
249
273
  captureException(err: unknown, extra?: Partial<PionneEvent>): void {
@@ -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
+ }