@pionne/node 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -77,10 +77,67 @@ Pionne.setTags({ tier: 'pro' });
77
77
  Pionne.setEnabled(false);
78
78
  ```
79
79
 
80
+ ### Bundle ID pinning — N/A on Node
81
+
82
+ The "Bundle ID" anti-token-theft check on Pionne projects is **mobile only**
83
+ (iOS/Android/RN/Flutter). On Node, your token lives in `.env` / a secrets
84
+ manager / EAS env vars — never in a decompilable binary — so the threat
85
+ doesn't exist. The field is hidden in the mobile dashboard for Node
86
+ projects; **don't set it manually via the API** — the SDK does not send a
87
+ top-level `app_id`, so a non-null `bundle_id` would 403 every event. Use
88
+ `tags` to differentiate deployments instead. See the
89
+ [Bundle ID Pinning docs](https://pionne.agkgcreations.fr/security/bundle-id#backends-sans-bundle_id).
90
+
91
+ ### Geography (opt-in)
92
+
93
+ Approximate server location (city, region, country) attached to every event,
94
+ just like Sentry. Off by default for privacy — flip `sendGeography` to enable:
95
+
96
+ ```ts
97
+ Pionne.init({
98
+ token: 'pio_live_xxx',
99
+ sendGeography: true,
100
+ });
101
+ ```
102
+
103
+ Resolved once at startup via a free IP→geo lookup (`https://ipapi.co/json/`
104
+ by default), with a 4 s timeout. If the lookup fails the SDK silently keeps
105
+ shipping events without geo. Override the endpoint via `geographyEndpoint`
106
+ if you have your own.
107
+
80
108
  ## Options
81
109
 
82
- Same shape as `@pionne/web` and `@pionne/react-native`. See the type
83
- definitions for the full list.
110
+ Same shape as `@pionne/web` and `@pionne/react-native`. Highlights :
111
+
112
+ | Option | Type | Default |
113
+ | --------------------- | -------------------------- | ------------------------ |
114
+ | `token` | `string` (required) | — |
115
+ | `endpoint` | `string` | Pionne production |
116
+ | `release` | `string` | unset |
117
+ | `environment` | `string` | `NODE_ENV` ou `production` |
118
+ | `enabled` | `boolean` | `true` |
119
+ | `captureUncaughtErrors` | `boolean` | `true` |
120
+ | `captureUnhandledRejections` | `boolean` | `true` |
121
+ | `tags` | `Record<string, string>` | unset |
122
+ | `userIdAnon` | `string` | unset |
123
+ | `maxStackFrames` | `number` | `50` |
124
+ | `beforeSend` | `(event) => event \| null` | unset (drop if `null`) |
125
+ | `sendGeography` | `boolean` | `false` |
126
+ | `geographyEndpoint` | `string` | `https://ipapi.co/json/` |
127
+ | `releaseHealth` | `boolean` | `true` |
128
+ | `maxEventsPerSecond` | `number` | `10` |
129
+
130
+ ### Notes
131
+
132
+ - **`maxEventsPerSecond`** — token-bucket process-wide. Au-delà, les events sont droppés silencieusement. Protège contre une boucle d'erreur dans un worker. `0` désactive (déconseillé sur des serveurs longue durée).
133
+ - **`releaseHealth`** — ouvre une session au `init()`, utile pour les services Node longue durée que tu veux mesurer comme un mobile (crash-free uptime).
134
+ - **`sendGeography`** — opt-in : résout city/region/country côté IP via [ipapi.co](https://ipapi.co/json/) par défaut. Sur un serveur, l'IP source est l'IP publique du serveur lui-même (pas celle d'un user). Customize via `geographyEndpoint` pour pointer sur ta propre lookup ou un proxy interne.
135
+
136
+ Voir les types TypeScript pour la liste complète.
137
+
138
+ ## Rate limit serveur
139
+
140
+ L'API Pionne cap **600 req/min/token** (= 10/sec) sur tous les endpoints publics (`/ingest`, `/sessions`, `/feedback`). Au-delà → `HTTP 429` avec un header `Retry-After`. Le SDK fait silencieusement échouer (try/catch interne). Empêche un token leaké (ou un worker qui boucle) de drainer ton infra ou ton quota mensuel. Voir [doc rate limits](https://pionne.agkgcreations.fr/security/rate-limits).
84
141
 
85
142
  ## License
86
143
 
package/dist/index.cjs CHANGED
@@ -156,6 +156,7 @@ function flipFromEvent(level, mechanismType) {
156
156
 
157
157
  // src/index.ts
158
158
  var DEFAULT_ENDPOINT = "https://pionne.agkgcreations.fr/api/ingest";
159
+ var DEFAULT_GEO_ENDPOINT = "https://ipapi.co/json/";
159
160
  var DEFAULT_MAX_STACK = 50;
160
161
  var SDK_NAME = "pionne.node";
161
162
  var SDK_VERSION = "0.1.0";
@@ -248,6 +249,31 @@ async function send(event) {
248
249
  } catch {
249
250
  }
250
251
  }
252
+ function fetchGeography(endpoint) {
253
+ if (typeof fetch !== "function") return;
254
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
255
+ const timeout = controller ? setTimeout(() => controller.abort(), 4e3) : null;
256
+ fetch(endpoint, {
257
+ method: "GET",
258
+ headers: { Accept: "application/json" },
259
+ signal: controller?.signal
260
+ }).then((res) => res.ok ? res.json() : null).then((data) => {
261
+ if (!data || typeof data !== "object") return;
262
+ const d = data;
263
+ const geo = {};
264
+ if (typeof d.city === "string") geo.city = d.city;
265
+ if (typeof d.region === "string") geo.region = d.region;
266
+ if (typeof d.country_name === "string") geo.country = d.country_name;
267
+ else if (typeof d.country === "string") geo.country = d.country;
268
+ if (typeof d.country_code === "string") geo.country_code = d.country_code;
269
+ if (Object.keys(geo).length === 0) return;
270
+ const ctx = staticContext.contexts ?? {};
271
+ staticContext.contexts = { ...ctx, geo };
272
+ }).catch(() => {
273
+ }).finally(() => {
274
+ if (timeout) clearTimeout(timeout);
275
+ });
276
+ }
251
277
  function installUncaughtHandler() {
252
278
  onUncaught = (err) => {
253
279
  const event = buildEvent(err, "fatal", "uncaughtException", false);
@@ -300,10 +326,13 @@ var Pionne = {
300
326
  beforeSend: options.beforeSend,
301
327
  userIdAnon: options.userIdAnon,
302
328
  tags: options.tags,
303
- maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
329
+ maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,
330
+ sendGeography: options.sendGeography ?? false,
331
+ geographyEndpoint: options.geographyEndpoint ?? DEFAULT_GEO_ENDPOINT
304
332
  };
305
333
  if (config.captureUncaughtExceptions) installUncaughtHandler();
306
334
  if (config.captureUnhandledRejections) installRejectionHandler();
335
+ if (config.sendGeography) fetchGeography(config.geographyEndpoint);
307
336
  if (options.releaseHealth !== false) {
308
337
  startSession({
309
338
  endpoint: config.endpoint,
@@ -1 +1 @@
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"]}
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 * Opt-in: resolve approximate server geography (city, region, country) once\n * at startup via a free IP→location lookup, and attach it to every event\n * under `contexts.geo`. Off by default for privacy.\n */\n sendGeography?: boolean;\n /**\n * Override the IP→geography endpoint. Must return JSON with at least\n * `city`, `region`, `country` (or `country_name`), and `country_code`\n * fields. Default: `https://ipapi.co/json/`.\n */\n geographyEndpoint?: string;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_GEO_ENDPOINT = 'https://ipapi.co/json/';\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\n/**\n * Fire-and-forget IP→geo lookup. Mutates `staticContext.contexts.geo` once it\n * resolves so subsequent events carry the location. Failures are silent — a\n * monitoring SDK must never crash or stall the host process.\n */\nfunction fetchGeography(endpoint: string): void {\n if (typeof fetch !== 'function') return;\n const controller =\n typeof AbortController !== 'undefined' ? new AbortController() : null;\n const timeout = controller\n ? setTimeout(() => controller.abort(), 4000)\n : null;\n fetch(endpoint, {\n method: 'GET',\n headers: { Accept: 'application/json' },\n signal: controller?.signal,\n })\n .then((res) => (res.ok ? res.json() : null))\n .then((data: unknown) => {\n if (!data || typeof data !== 'object') return;\n const d = data as Record<string, unknown>;\n const geo: Record<string, string> = {};\n if (typeof d.city === 'string') geo.city = d.city;\n if (typeof d.region === 'string') geo.region = d.region;\n if (typeof d.country_name === 'string') geo.country = d.country_name;\n else if (typeof d.country === 'string') geo.country = d.country;\n if (typeof d.country_code === 'string') geo.country_code = d.country_code;\n if (Object.keys(geo).length === 0) return;\n const ctx = staticContext.contexts ?? {};\n staticContext.contexts = { ...ctx, geo };\n })\n .catch(() => {\n // Best-effort: silently ignore lookup failures.\n })\n .finally(() => {\n if (timeout) clearTimeout(timeout);\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 sendGeography: options.sendGeography ?? false,\n geographyEndpoint: options.geographyEndpoint ?? DEFAULT_GEO_ENDPOINT,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n if (config.sendGeography) fetchGeography(config.geographyEndpoint);\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
@@ -43,6 +43,18 @@ interface PionneOptions {
43
43
  releaseHealth?: boolean;
44
44
  /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
45
45
  maxEventsPerSecond?: number;
46
+ /**
47
+ * Opt-in: resolve approximate server geography (city, region, country) once
48
+ * at startup via a free IP→location lookup, and attach it to every event
49
+ * under `contexts.geo`. Off by default for privacy.
50
+ */
51
+ sendGeography?: boolean;
52
+ /**
53
+ * Override the IP→geography endpoint. Must return JSON with at least
54
+ * `city`, `region`, `country` (or `country_name`), and `country_code`
55
+ * fields. Default: `https://ipapi.co/json/`.
56
+ */
57
+ geographyEndpoint?: string;
46
58
  }
47
59
  declare const Pionne: {
48
60
  init(options: PionneOptions): void;
package/dist/index.d.ts CHANGED
@@ -43,6 +43,18 @@ interface PionneOptions {
43
43
  releaseHealth?: boolean;
44
44
  /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
45
45
  maxEventsPerSecond?: number;
46
+ /**
47
+ * Opt-in: resolve approximate server geography (city, region, country) once
48
+ * at startup via a free IP→location lookup, and attach it to every event
49
+ * under `contexts.geo`. Off by default for privacy.
50
+ */
51
+ sendGeography?: boolean;
52
+ /**
53
+ * Override the IP→geography endpoint. Must return JSON with at least
54
+ * `city`, `region`, `country` (or `country_name`), and `country_code`
55
+ * fields. Default: `https://ipapi.co/json/`.
56
+ */
57
+ geographyEndpoint?: string;
46
58
  }
47
59
  declare const Pionne: {
48
60
  init(options: PionneOptions): void;
package/dist/index.mjs CHANGED
@@ -121,6 +121,7 @@ function flipFromEvent(level, mechanismType) {
121
121
 
122
122
  // src/index.ts
123
123
  var DEFAULT_ENDPOINT = "https://pionne.agkgcreations.fr/api/ingest";
124
+ var DEFAULT_GEO_ENDPOINT = "https://ipapi.co/json/";
124
125
  var DEFAULT_MAX_STACK = 50;
125
126
  var SDK_NAME = "pionne.node";
126
127
  var SDK_VERSION = "0.1.0";
@@ -213,6 +214,31 @@ async function send(event) {
213
214
  } catch {
214
215
  }
215
216
  }
217
+ function fetchGeography(endpoint) {
218
+ if (typeof fetch !== "function") return;
219
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
220
+ const timeout = controller ? setTimeout(() => controller.abort(), 4e3) : null;
221
+ fetch(endpoint, {
222
+ method: "GET",
223
+ headers: { Accept: "application/json" },
224
+ signal: controller?.signal
225
+ }).then((res) => res.ok ? res.json() : null).then((data) => {
226
+ if (!data || typeof data !== "object") return;
227
+ const d = data;
228
+ const geo = {};
229
+ if (typeof d.city === "string") geo.city = d.city;
230
+ if (typeof d.region === "string") geo.region = d.region;
231
+ if (typeof d.country_name === "string") geo.country = d.country_name;
232
+ else if (typeof d.country === "string") geo.country = d.country;
233
+ if (typeof d.country_code === "string") geo.country_code = d.country_code;
234
+ if (Object.keys(geo).length === 0) return;
235
+ const ctx = staticContext.contexts ?? {};
236
+ staticContext.contexts = { ...ctx, geo };
237
+ }).catch(() => {
238
+ }).finally(() => {
239
+ if (timeout) clearTimeout(timeout);
240
+ });
241
+ }
216
242
  function installUncaughtHandler() {
217
243
  onUncaught = (err) => {
218
244
  const event = buildEvent(err, "fatal", "uncaughtException", false);
@@ -265,10 +291,13 @@ var Pionne = {
265
291
  beforeSend: options.beforeSend,
266
292
  userIdAnon: options.userIdAnon,
267
293
  tags: options.tags,
268
- maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK
294
+ maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,
295
+ sendGeography: options.sendGeography ?? false,
296
+ geographyEndpoint: options.geographyEndpoint ?? DEFAULT_GEO_ENDPOINT
269
297
  };
270
298
  if (config.captureUncaughtExceptions) installUncaughtHandler();
271
299
  if (config.captureUnhandledRejections) installRejectionHandler();
300
+ if (config.sendGeography) fetchGeography(config.geographyEndpoint);
272
301
  if (options.releaseHealth !== false) {
273
302
  startSession({
274
303
  endpoint: config.endpoint,
@@ -1 +1 @@
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"]}
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 * Opt-in: resolve approximate server geography (city, region, country) once\n * at startup via a free IP→location lookup, and attach it to every event\n * under `contexts.geo`. Off by default for privacy.\n */\n sendGeography?: boolean;\n /**\n * Override the IP→geography endpoint. Must return JSON with at least\n * `city`, `region`, `country` (or `country_name`), and `country_code`\n * fields. Default: `https://ipapi.co/json/`.\n */\n geographyEndpoint?: string;\n}\n\nconst DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';\nconst DEFAULT_GEO_ENDPOINT = 'https://ipapi.co/json/';\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\n/**\n * Fire-and-forget IP→geo lookup. Mutates `staticContext.contexts.geo` once it\n * resolves so subsequent events carry the location. Failures are silent — a\n * monitoring SDK must never crash or stall the host process.\n */\nfunction fetchGeography(endpoint: string): void {\n if (typeof fetch !== 'function') return;\n const controller =\n typeof AbortController !== 'undefined' ? new AbortController() : null;\n const timeout = controller\n ? setTimeout(() => controller.abort(), 4000)\n : null;\n fetch(endpoint, {\n method: 'GET',\n headers: { Accept: 'application/json' },\n signal: controller?.signal,\n })\n .then((res) => (res.ok ? res.json() : null))\n .then((data: unknown) => {\n if (!data || typeof data !== 'object') return;\n const d = data as Record<string, unknown>;\n const geo: Record<string, string> = {};\n if (typeof d.city === 'string') geo.city = d.city;\n if (typeof d.region === 'string') geo.region = d.region;\n if (typeof d.country_name === 'string') geo.country = d.country_name;\n else if (typeof d.country === 'string') geo.country = d.country;\n if (typeof d.country_code === 'string') geo.country_code = d.country_code;\n if (Object.keys(geo).length === 0) return;\n const ctx = staticContext.contexts ?? {};\n staticContext.contexts = { ...ctx, geo };\n })\n .catch(() => {\n // Best-effort: silently ignore lookup failures.\n })\n .finally(() => {\n if (timeout) clearTimeout(timeout);\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 sendGeography: options.sendGeography ?? false,\n geographyEndpoint: options.geographyEndpoint ?? DEFAULT_GEO_ENDPOINT,\n };\n\n if (config.captureUncaughtExceptions) installUncaughtHandler();\n if (config.captureUnhandledRejections) installRejectionHandler();\n if (config.sendGeography) fetchGeography(config.geographyEndpoint);\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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pionne/node",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
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",
package/src/index.ts CHANGED
@@ -62,9 +62,22 @@ export interface PionneOptions {
62
62
  releaseHealth?: boolean;
63
63
  /** Token-bucket rate limit (events/sec). Default 10, set 0 to disable. */
64
64
  maxEventsPerSecond?: number;
65
+ /**
66
+ * Opt-in: resolve approximate server geography (city, region, country) once
67
+ * at startup via a free IP→location lookup, and attach it to every event
68
+ * under `contexts.geo`. Off by default for privacy.
69
+ */
70
+ sendGeography?: boolean;
71
+ /**
72
+ * Override the IP→geography endpoint. Must return JSON with at least
73
+ * `city`, `region`, `country` (or `country_name`), and `country_code`
74
+ * fields. Default: `https://ipapi.co/json/`.
75
+ */
76
+ geographyEndpoint?: string;
65
77
  }
66
78
 
67
79
  const DEFAULT_ENDPOINT = 'https://pionne.agkgcreations.fr/api/ingest';
80
+ const DEFAULT_GEO_ENDPOINT = 'https://ipapi.co/json/';
68
81
  const DEFAULT_MAX_STACK = 50;
69
82
  const SDK_NAME = 'pionne.node';
70
83
  const SDK_VERSION = '0.1.0';
@@ -187,6 +200,45 @@ async function send(event: PionneEvent): Promise<void> {
187
200
  }
188
201
  }
189
202
 
203
+ /**
204
+ * Fire-and-forget IP→geo lookup. Mutates `staticContext.contexts.geo` once it
205
+ * resolves so subsequent events carry the location. Failures are silent — a
206
+ * monitoring SDK must never crash or stall the host process.
207
+ */
208
+ function fetchGeography(endpoint: string): void {
209
+ if (typeof fetch !== 'function') return;
210
+ const controller =
211
+ typeof AbortController !== 'undefined' ? new AbortController() : null;
212
+ const timeout = controller
213
+ ? setTimeout(() => controller.abort(), 4000)
214
+ : null;
215
+ fetch(endpoint, {
216
+ method: 'GET',
217
+ headers: { Accept: 'application/json' },
218
+ signal: controller?.signal,
219
+ })
220
+ .then((res) => (res.ok ? res.json() : null))
221
+ .then((data: unknown) => {
222
+ if (!data || typeof data !== 'object') return;
223
+ const d = data as Record<string, unknown>;
224
+ const geo: Record<string, string> = {};
225
+ if (typeof d.city === 'string') geo.city = d.city;
226
+ if (typeof d.region === 'string') geo.region = d.region;
227
+ if (typeof d.country_name === 'string') geo.country = d.country_name;
228
+ else if (typeof d.country === 'string') geo.country = d.country;
229
+ if (typeof d.country_code === 'string') geo.country_code = d.country_code;
230
+ if (Object.keys(geo).length === 0) return;
231
+ const ctx = staticContext.contexts ?? {};
232
+ staticContext.contexts = { ...ctx, geo };
233
+ })
234
+ .catch(() => {
235
+ // Best-effort: silently ignore lookup failures.
236
+ })
237
+ .finally(() => {
238
+ if (timeout) clearTimeout(timeout);
239
+ });
240
+ }
241
+
190
242
  function installUncaughtHandler(): void {
191
243
  onUncaught = (err: Error) => {
192
244
  const event = buildEvent(err, 'fatal', 'uncaughtException', false);
@@ -247,10 +299,13 @@ export const Pionne = {
247
299
  userIdAnon: options.userIdAnon,
248
300
  tags: options.tags,
249
301
  maxStackFrames: options.maxStackFrames ?? DEFAULT_MAX_STACK,
302
+ sendGeography: options.sendGeography ?? false,
303
+ geographyEndpoint: options.geographyEndpoint ?? DEFAULT_GEO_ENDPOINT,
250
304
  };
251
305
 
252
306
  if (config.captureUncaughtExceptions) installUncaughtHandler();
253
307
  if (config.captureUnhandledRejections) installRejectionHandler();
308
+ if (config.sendGeography) fetchGeography(config.geographyEndpoint);
254
309
 
255
310
  // Release Health — open a session unless the host opted out.
256
311
  if (options.releaseHealth !== false) {