@glidevvr/storage-payload-error-logger-pkg 0.1.0

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.
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/react/index.ts
21
+ var react_exports = {};
22
+ __export(react_exports, {
23
+ ErrorBoundary: () => ErrorBoundary
24
+ });
25
+ module.exports = __toCommonJS(react_exports);
26
+
27
+ // src/react/ErrorBoundary.tsx
28
+ var import_react = require("react");
29
+
30
+ // src/initLogger.ts
31
+ var config = null;
32
+ function _getConfig() {
33
+ return config;
34
+ }
35
+
36
+ // src/fingerprint.ts
37
+ function fingerprint(source, message, stack, reason) {
38
+ const topFrame = stack.split("\n", 2)[0] ?? "";
39
+ const reasonPart = reason ?? "";
40
+ const input = `${source}|${message}|${topFrame}|${reasonPart}`;
41
+ let hash = 5381;
42
+ for (let i = 0; i < input.length; i++) {
43
+ hash = (hash << 5) + hash + input.charCodeAt(i);
44
+ hash |= 0;
45
+ }
46
+ return (hash >>> 0).toString(16);
47
+ }
48
+
49
+ // src/context.ts
50
+ var current = {};
51
+ var isBrowser = () => typeof window !== "undefined";
52
+ function getLogContext() {
53
+ if (!isBrowser()) return {};
54
+ return { ...current };
55
+ }
56
+
57
+ // src/throttle.ts
58
+ function createThrottle(opts) {
59
+ const lastEmittedAt = /* @__PURE__ */ new Map();
60
+ return {
61
+ shouldEmit(fingerprint2) {
62
+ const now = Date.now();
63
+ const last = lastEmittedAt.get(fingerprint2);
64
+ if (last !== void 0 && now - last < opts.windowMs) return false;
65
+ lastEmittedAt.set(fingerprint2, now);
66
+ return true;
67
+ }
68
+ };
69
+ }
70
+
71
+ // src/batch.ts
72
+ function createBatcher(opts) {
73
+ let queue = [];
74
+ let timer = null;
75
+ async function flush() {
76
+ if (timer) {
77
+ clearTimeout(timer);
78
+ timer = null;
79
+ }
80
+ if (queue.length === 0) return;
81
+ const drained = queue;
82
+ queue = [];
83
+ await opts.onFlush(drained);
84
+ }
85
+ return {
86
+ enqueue(item) {
87
+ queue.push(item);
88
+ if (queue.length >= opts.maxQueueLength) {
89
+ void flush();
90
+ return;
91
+ }
92
+ if (timer === null) {
93
+ timer = setTimeout(() => {
94
+ void flush();
95
+ }, opts.flushMs);
96
+ }
97
+ },
98
+ flushOnce: flush
99
+ };
100
+ }
101
+
102
+ // src/circuitBreaker.ts
103
+ function createCircuitBreaker(opts) {
104
+ let failures = [];
105
+ let openUntil = 0;
106
+ return {
107
+ canTransmit() {
108
+ return Date.now() >= openUntil;
109
+ },
110
+ recordFailure() {
111
+ const now = Date.now();
112
+ failures = failures.filter((t) => now - t < opts.failureWindowMs);
113
+ failures.push(now);
114
+ if (failures.length >= opts.failureThreshold) {
115
+ openUntil = now + opts.openMs;
116
+ failures = [];
117
+ }
118
+ },
119
+ recordSuccess() {
120
+ failures = [];
121
+ }
122
+ };
123
+ }
124
+
125
+ // src/recaptcha.ts
126
+ async function acquireRecaptchaToken(siteKey, action) {
127
+ const g = typeof globalThis !== "undefined" ? globalThis.grecaptcha : void 0;
128
+ if (!g?.enterprise) return null;
129
+ try {
130
+ await new Promise((resolve) => g.enterprise.ready(resolve));
131
+ return await g.enterprise.execute(siteKey, { action });
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // src/severity.ts
138
+ function getDefaultSeverity(reason) {
139
+ if (!reason) return "error";
140
+ switch (reason) {
141
+ // user-recoverable / expected-flow signals
142
+ case "validation_error":
143
+ case "payment_declined":
144
+ case "invalid_payment_option":
145
+ case "unit_list_empty":
146
+ case "expected_data_missing":
147
+ case "cache_stale":
148
+ case "promotion_not_found":
149
+ return "warning";
150
+ // informational
151
+ case "session_expired":
152
+ return "info";
153
+ // everything else (network failures, unauthorized, not-founds we couldn't
154
+ // recover from, hook/scraper/build failures, unhandled exceptions) — the
155
+ // user is blocked, treat as error
156
+ default:
157
+ return "error";
158
+ }
159
+ }
160
+
161
+ // src/transport.ts
162
+ function isBrowser2() {
163
+ return typeof window !== "undefined";
164
+ }
165
+ async function postFromBrowser(endpoint, request) {
166
+ try {
167
+ const res = await fetch(endpoint, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify(request),
171
+ keepalive: true
172
+ });
173
+ return { ok: res.ok, status: res.status };
174
+ } catch {
175
+ return { ok: false, status: 0 };
176
+ }
177
+ }
178
+ async function postFromServer(payloadBaseUrl, apiKey, events) {
179
+ let lastStatus = 0;
180
+ for (const event of events) {
181
+ try {
182
+ const res = await fetch(`${payloadBaseUrl}/cms/api/issue-events`, {
183
+ method: "POST",
184
+ headers: {
185
+ "Content-Type": "application/json",
186
+ Authorization: `users API-Key ${apiKey}`
187
+ },
188
+ body: JSON.stringify(event)
189
+ });
190
+ lastStatus = res.status;
191
+ if (!res.ok) return { ok: false, status: res.status };
192
+ } catch {
193
+ return { ok: false, status: 0 };
194
+ }
195
+ }
196
+ return { ok: true, status: lastStatus };
197
+ }
198
+
199
+ // src/logError.ts
200
+ var throttle = createThrottle({ windowMs: 5e3 });
201
+ var breaker = createCircuitBreaker({
202
+ failureThreshold: 3,
203
+ failureWindowMs: 3e4,
204
+ openMs: 6e4
205
+ });
206
+ var batcher = null;
207
+ function getBatcher() {
208
+ if (batcher) return batcher;
209
+ batcher = createBatcher({
210
+ flushMs: 3e3,
211
+ maxQueueLength: 10,
212
+ onFlush: async (events) => {
213
+ const cfg = _getConfig();
214
+ if (!cfg) return;
215
+ if (!breaker.canTransmit()) return;
216
+ const rcToken = cfg.recaptchaSiteKey ? await acquireRecaptchaToken(cfg.recaptchaSiteKey, cfg.recaptchaAction ?? "log_error") : null;
217
+ const result = await postFromBrowser(cfg.endpoint, { events, rcToken });
218
+ if (result.ok) breaker.recordSuccess();
219
+ else if (result.status >= 500 || result.status === 0) breaker.recordFailure();
220
+ }
221
+ });
222
+ return batcher;
223
+ }
224
+ function safePageUrl() {
225
+ if (!isBrowser2()) return "";
226
+ try {
227
+ const parsed = new URL(window.location.href);
228
+ return parsed.origin + parsed.pathname;
229
+ } catch {
230
+ return "";
231
+ }
232
+ }
233
+ function buildEvent(err, opts) {
234
+ const cfg = _getConfig();
235
+ if (!cfg) return null;
236
+ const message = err instanceof Error ? err.message : String(err);
237
+ const stack = err instanceof Error && err.stack ? err.stack : "";
238
+ const ambient = getLogContext();
239
+ const source = opts?.source ?? ambient.source ?? cfg.defaultSource ?? "theme";
240
+ const reason = opts?.reason;
241
+ const severity = opts?.severity ?? getDefaultSeverity(reason);
242
+ const fp = fingerprint(source, message, stack, reason);
243
+ const userAgent = isBrowser2() && typeof navigator !== "undefined" ? navigator.userAgent : "";
244
+ return {
245
+ source,
246
+ repo: cfg.repo,
247
+ severity,
248
+ fingerprint: fp,
249
+ message: message.slice(0, 500),
250
+ stack: stack.slice(0, 1e4),
251
+ context: { ...ambient, ...opts?.context ?? {} },
252
+ url: safePageUrl(),
253
+ userAgent,
254
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
255
+ release: cfg.release,
256
+ reason
257
+ };
258
+ }
259
+ function logError(err, opts) {
260
+ try {
261
+ const event = buildEvent(err, opts);
262
+ if (!event) return;
263
+ if (!throttle.shouldEmit(event.fingerprint)) return;
264
+ if (isBrowser2()) {
265
+ getBatcher().enqueue(event);
266
+ } else {
267
+ const cfg = _getConfig();
268
+ if (!cfg) return;
269
+ const apiKey = process.env.TENANT_API_KEY ?? process.env.PAYLOAD_PUBLIC_API_KEY;
270
+ const baseUrl = process.env.PAYLOAD_PUBLIC_URL;
271
+ if (!apiKey || !baseUrl) return;
272
+ void postFromServer(baseUrl, apiKey, [event]).catch(() => {
273
+ });
274
+ }
275
+ } catch {
276
+ }
277
+ }
278
+
279
+ // src/react/ErrorBoundary.tsx
280
+ var ErrorBoundary = class extends import_react.Component {
281
+ state = { hasError: false };
282
+ static getDerivedStateFromError() {
283
+ return { hasError: true };
284
+ }
285
+ componentDidCatch(error, errorInfo) {
286
+ logError(error, {
287
+ context: { componentStack: errorInfo.componentStack ?? "" }
288
+ });
289
+ this.props.onError?.(error, errorInfo);
290
+ }
291
+ render() {
292
+ if (this.state.hasError) {
293
+ return this.props.fallback ?? null;
294
+ }
295
+ return this.props.children;
296
+ }
297
+ };
298
+ // Annotate the CommonJS export names for ESM import in node:
299
+ 0 && (module.exports = {
300
+ ErrorBoundary
301
+ });
302
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/index.ts","../../src/react/ErrorBoundary.tsx","../../src/initLogger.ts","../../src/fingerprint.ts","../../src/context.ts","../../src/throttle.ts","../../src/batch.ts","../../src/circuitBreaker.ts","../../src/recaptcha.ts","../../src/severity.ts","../../src/transport.ts","../../src/logError.ts"],"sourcesContent":["export { ErrorBoundary } from \"./ErrorBoundary\";\nexport type { ErrorBoundaryProps } from \"./ErrorBoundary\";\n","import { Component, type ErrorInfo, type ReactNode } from \"react\";\nimport { logError } from \"../logError\";\n\nexport interface ErrorBoundaryProps {\n children: ReactNode;\n /** Optional fallback to render when an error is caught. */\n fallback?: ReactNode;\n /** Optional callback invoked alongside logError. */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n}\n\ninterface ErrorBoundaryState {\n hasError: boolean;\n}\n\n/**\n * React error boundary that forwards rendering errors to the centralized logger.\n *\n * Use at component-tree boundaries where you want to scope a render failure\n * (e.g., wrapping a widget so a render error there doesn't crash the host page).\n */\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n state: ErrorBoundaryState = { hasError: false };\n\n static getDerivedStateFromError(): ErrorBoundaryState {\n return { hasError: true };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n logError(error, {\n context: { componentStack: errorInfo.componentStack ?? \"\" },\n });\n this.props.onError?.(error, errorInfo);\n }\n\n render(): ReactNode {\n if (this.state.hasError) {\n return this.props.fallback ?? null;\n }\n return this.props.children;\n }\n}\n","import type { InitLoggerConfig } from \"./types\";\n\nlet config: InitLoggerConfig | null = null;\n\n/**\n * Set up the logger. Call once at app startup.\n *\n * Required fields: `endpoint` (where the browser sends events) and `repo`\n * (stamped on every event). Optional: `release`, `recaptchaSiteKey`,\n * `recaptchaAction`, `defaultSource`. Throws if a required field is missing\n * so misconfiguration is loud at startup instead of silently dropping events.\n */\nexport function initLogger(cfg: InitLoggerConfig): void {\n if (!cfg?.endpoint) throw new Error(\"initLogger: endpoint is required\");\n if (!cfg?.repo) throw new Error(\"initLogger: repo is required\");\n config = { ...cfg };\n}\n\n/** @internal — read the current config; returns null before `initLogger` runs. */\nexport function _getConfig(): InitLoggerConfig | null {\n return config;\n}\n\n/** @internal — test-only */\nexport function _resetForTests(): void {\n config = null;\n}\n","/**\n * Compute a short stable hash that identifies \"the same bug\" across occurrences.\n * Used by the dedup hook to find-or-create the parent `Issues` row.\n *\n * Only the top frame of the stack is hashed so a bug fired from the same place\n * groups together even if surrounding code paths differ. Returned as a hex\n * string just to keep it compact; we just need stable grouping, not security.\n *\n * `reason` is included so the same code path failing for different reasons\n * (e.g. `invalid_cvv` vs `gateway_decline` at the payment step) becomes two\n * separate Issues. An empty / omitted reason hashes the same as `\"\"`, so\n * existing 3-arg callers stay compatible.\n */\nexport function fingerprint(\n source: string,\n message: string,\n stack: string,\n reason?: string,\n): string {\n const topFrame = stack.split(\"\\n\", 2)[0] ?? \"\";\n const reasonPart = reason ?? \"\";\n const input = `${source}|${message}|${topFrame}|${reasonPart}`;\n let hash = 5381;\n for (let i = 0; i < input.length; i++) {\n hash = ((hash << 5) + hash) + input.charCodeAt(i);\n hash |= 0;\n }\n return (hash >>> 0).toString(16);\n}\n","/**\n * @fileoverview Ambient log context store — browser-only.\n *\n * On the browser the module-level `current` object is a per-tab singleton, the\n * scope we want. On Node (SSR), the same module is shared across every\n * incoming request running in the process, so writing to `current` in one\n * request would leak that context into other requests' error events. To stay\n * safe, all three functions no-op (or return `{}`) when there is no `window`.\n *\n * **Server callers must pass context explicitly** via the `opts.context`\n * parameter on `logError`, which is merged into the event regardless of the\n * ambient state.\n */\nimport type { LogContext } from \"./types\";\n\nlet current: LogContext = {};\n\nconst isBrowser = (): boolean => typeof window !== \"undefined\";\n\n/**\n * Add or overwrite keys in the ambient log context. Existing keys you don't\n * pass are preserved; keys you do pass replace existing values (no deep merge).\n *\n * Call at meaningful UX boundaries (page mount, step change, modal open) so\n * later errors automatically pick up the relevant context.\n *\n * No-op on the server — see file-level note above.\n */\nexport function setLogContext(patch: LogContext): void {\n if (!isBrowser()) return;\n current = { ...current, ...patch };\n}\n\n/**\n * Return a copy of the current ambient log context. Used by `logError` to\n * attach context to outgoing events.\n *\n * Returns `{}` on the server — see file-level note above.\n */\nexport function getLogContext(): LogContext {\n if (!isBrowser()) return {};\n return { ...current };\n}\n\n/**\n * Run `fn` with extra context keys layered on top of the ambient context, then\n * put the original context back — even if `fn` throws.\n *\n * Use this when the extra keys should only apply to one synchronous operation\n * and not leak into anything that runs afterwards.\n *\n * On the server `fn` runs without any ambient mutation — see file-level note above.\n */\nexport function withLogContext<T>(scope: LogContext, fn: () => T): T {\n if (!isBrowser()) return fn();\n const prior = current;\n current = { ...current, ...scope };\n try {\n return fn();\n } finally {\n current = prior;\n }\n}\n\n/** @internal — test-only */\nexport function _resetForTests(): void {\n current = {};\n}\n","/** Throttle that decides whether a given fingerprint should be sent right now. */\nexport interface Throttle {\n shouldEmit(fingerprint: string): boolean;\n}\n\n/**\n * Returns a throttle that lets the first occurrence of each fingerprint through\n * and silently drops repeats until `windowMs` passes. Stops a bug firing in a\n * tight loop from flooding the CMS.\n */\nexport function createThrottle(opts: { windowMs: number }): Throttle {\n const lastEmittedAt = new Map<string, number>();\n return {\n shouldEmit(fingerprint: string): boolean {\n const now = Date.now();\n const last = lastEmittedAt.get(fingerprint);\n if (last !== undefined && now - last < opts.windowMs) return false;\n lastEmittedAt.set(fingerprint, now);\n return true;\n },\n };\n}\n","/** A buffer that collects items and flushes them in groups. */\nexport interface Batcher<T> {\n enqueue(item: T): void;\n flushOnce(): Promise<void>;\n}\n\nexport interface BatcherOptions<T> {\n /** Auto-flush this many ms after the first item is enqueued. */\n flushMs: number;\n /** Flush right away once the queue reaches this length. */\n maxQueueLength: number;\n /** Called with the queued items when the batch flushes. */\n onFlush: (events: T[]) => void | Promise<void>;\n}\n\n/**\n * Returns a buffer that flushes when either the time limit or the size limit\n * is reached, whichever comes first. `flushOnce()` empties the queue right\n * away on demand. Lets us send a batch of events in one HTTP request instead\n * of one request per event.\n */\nexport function createBatcher<T>(opts: BatcherOptions<T>): Batcher<T> {\n let queue: T[] = [];\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n async function flush(): Promise<void> {\n if (timer) { clearTimeout(timer); timer = null; }\n if (queue.length === 0) return;\n const drained = queue;\n queue = [];\n await opts.onFlush(drained);\n }\n\n return {\n enqueue(item: T): void {\n queue.push(item);\n if (queue.length >= opts.maxQueueLength) {\n void flush();\n return;\n }\n if (timer === null) {\n timer = setTimeout(() => { void flush(); }, opts.flushMs);\n }\n },\n flushOnce: flush,\n };\n}\n","/** Tracks whether a flaky downstream call should currently be attempted. */\nexport interface CircuitBreaker {\n canTransmit(): boolean;\n recordFailure(): void;\n recordSuccess(): void;\n}\n\nexport interface CircuitBreakerOptions {\n /** Number of recent failures that \"trips\" the breaker (stops sending). */\n failureThreshold: number;\n /** How far back to look when counting failures, in ms. */\n failureWindowMs: number;\n /** Once tripped, how long to wait before allowing sends again. */\n openMs: number;\n}\n\n/**\n * Returns a circuit breaker. After `failureThreshold` failures within\n * `failureWindowMs`, it stops allowing sends for `openMs`, then automatically\n * starts allowing them again.\n *\n * Used by `logError` to back off when the endpoint is failing, so we don't\n * keep hammering a struggling CMS.\n */\nexport function createCircuitBreaker(opts: CircuitBreakerOptions): CircuitBreaker {\n let failures: number[] = [];\n let openUntil = 0;\n\n return {\n canTransmit(): boolean {\n return Date.now() >= openUntil;\n },\n recordFailure(): void {\n const now = Date.now();\n failures = failures.filter((t) => now - t < opts.failureWindowMs);\n failures.push(now);\n if (failures.length >= opts.failureThreshold) {\n openUntil = now + opts.openMs;\n failures = [];\n }\n },\n recordSuccess(): void {\n failures = [];\n },\n };\n}\n","interface GrecaptchaEnterprise {\n ready: (cb: () => void) => void;\n execute: (siteKey: string, opts: { action: string }) => Promise<string>;\n}\n\ninterface GrecaptchaGlobal {\n enterprise: GrecaptchaEnterprise;\n}\n\ndeclare global {\n // eslint-disable-next-line no-var\n var grecaptcha: GrecaptchaGlobal | undefined;\n}\n\n/**\n * Acquire a reCAPTCHA Enterprise v3 token from the browser SDK.\n *\n * Returns null if the SDK isn't loaded yet (e.g. on the server, or before\n * `https://www.google.com/recaptcha/enterprise.js` finishes loading) or if\n * `execute` rejects. Never throws.\n */\nexport async function acquireRecaptchaToken(\n siteKey: string,\n action: string,\n): Promise<string | null> {\n const g = typeof globalThis !== \"undefined\" ? globalThis.grecaptcha : undefined;\n if (!g?.enterprise) return null;\n try {\n await new Promise<void>((resolve) => g.enterprise.ready(resolve));\n return await g.enterprise.execute(siteKey, { action });\n } catch {\n return null;\n }\n}\n","import type { Reason, Severity } from \"./types\";\n\n/**\n * Map a `Reason` to its default `Severity` when the caller hasn't passed\n * one explicitly.\n *\n * Convention:\n * - `error` — blocks the user / unrecoverable / 5xx upstream\n * - `warning` — recoverable, user-facing flow signal (validation,\n * declined card, soft-assert empty result)\n * - `info` — informational signal (`session_expired`)\n *\n * Wire this into integration points (e.g. the RTK Query error middleware)\n * so individual call sites don't have to think about severity unless they\n * want to override the default.\n */\nexport function getDefaultSeverity(reason?: Reason): Severity {\n if (!reason) return \"error\";\n\n switch (reason) {\n // user-recoverable / expected-flow signals\n case \"validation_error\":\n case \"payment_declined\":\n case \"invalid_payment_option\":\n case \"unit_list_empty\":\n case \"expected_data_missing\":\n case \"cache_stale\":\n case \"promotion_not_found\":\n return \"warning\";\n\n // informational\n case \"session_expired\":\n return \"info\";\n\n // everything else (network failures, unauthorized, not-founds we couldn't\n // recover from, hook/scraper/build failures, unhandled exceptions) — the\n // user is blocked, treat as error\n default:\n return \"error\";\n }\n}\n","import type { LogEvent } from \"./types\";\n\nexport interface TransportRequest {\n events: LogEvent[];\n rcToken: string | null;\n}\n\nexport interface TransportResult {\n ok: boolean;\n status: number;\n}\n\n/** Detects whether we're in a browser-like environment (has window). */\nexport function isBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\n/** Browser POST: cross-origin to the configured endpoint, no credentials. */\nexport async function postFromBrowser(\n endpoint: string,\n request: TransportRequest,\n): Promise<TransportResult> {\n try {\n const res = await fetch(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(request),\n keepalive: true,\n });\n return { ok: res.ok, status: res.status };\n } catch {\n return { ok: false, status: 0 };\n }\n}\n\n/**\n * Server POST: writes events directly to Payload's REST API for the IssueEvents collection.\n * Each event becomes one POST (the dedup hook on IssueEvents handles aggregation into Issues).\n *\n * `payloadBaseUrl` should be e.g. `https://payloadstorage.golocaldev.com` (no `/cms` suffix —\n * we append `/cms/api/issue-events` per the existing tenantInfo.ts convention).\n */\nexport async function postFromServer(\n payloadBaseUrl: string,\n apiKey: string,\n events: LogEvent[],\n): Promise<TransportResult> {\n let lastStatus = 0;\n for (const event of events) {\n try {\n const res = await fetch(`${payloadBaseUrl}/cms/api/issue-events`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `users API-Key ${apiKey}`,\n },\n body: JSON.stringify(event),\n });\n lastStatus = res.status;\n if (!res.ok) return { ok: false, status: res.status };\n } catch {\n return { ok: false, status: 0 };\n }\n }\n return { ok: true, status: lastStatus };\n}\n","import { _getConfig } from \"./initLogger\";\nimport { fingerprint } from \"./fingerprint\";\nimport { getLogContext } from \"./context\";\nimport { createThrottle } from \"./throttle\";\nimport { createBatcher } from \"./batch\";\nimport { createCircuitBreaker } from \"./circuitBreaker\";\nimport { acquireRecaptchaToken } from \"./recaptcha\";\nimport { getDefaultSeverity } from \"./severity\";\nimport { isBrowser, postFromBrowser, postFromServer } from \"./transport\";\nimport type { LogErrorOptions, LogEvent, Source, Severity } from \"./types\";\n\nconst throttle = createThrottle({ windowMs: 5_000 });\nconst breaker = createCircuitBreaker({\n failureThreshold: 3,\n failureWindowMs: 30_000,\n openMs: 60_000,\n});\n\nlet batcher: ReturnType<typeof createBatcher<LogEvent>> | null = null;\n\nfunction getBatcher(): ReturnType<typeof createBatcher<LogEvent>> {\n if (batcher) return batcher;\n batcher = createBatcher<LogEvent>({\n flushMs: 3_000,\n maxQueueLength: 10,\n onFlush: async (events) => {\n const cfg = _getConfig();\n if (!cfg) return;\n if (!breaker.canTransmit()) return;\n\n const rcToken = cfg.recaptchaSiteKey\n ? await acquireRecaptchaToken(cfg.recaptchaSiteKey, cfg.recaptchaAction ?? \"log_error\")\n : null;\n\n const result = await postFromBrowser(cfg.endpoint, { events, rcToken });\n if (result.ok) breaker.recordSuccess();\n else if (result.status >= 500 || result.status === 0) breaker.recordFailure();\n },\n });\n return batcher;\n}\n\n/**\n * Strip query string and hash from the current URL before logging it.\n *\n * **Why.** The PII contract says we don't accept user data into the pipeline.\n * Query strings can carry tokens, emails, or other inputs (e.g. `?email=…`\n * from a magic-link flow); the fragment can carry routing state that includes\n * the same. Path is enough to identify *which page* the bug fired on.\n */\nfunction safePageUrl(): string {\n if (!isBrowser()) return \"\";\n try {\n const parsed = new URL(window.location.href);\n return parsed.origin + parsed.pathname;\n } catch {\n return \"\";\n }\n}\n\nfunction buildEvent(\n err: Error | string | number | boolean | null | undefined,\n opts?: LogErrorOptions,\n): LogEvent | null {\n const cfg = _getConfig();\n if (!cfg) return null;\n\n const message = err instanceof Error ? err.message : String(err);\n const stack = err instanceof Error && err.stack ? err.stack : \"\";\n const ambient = getLogContext();\n const source: Source = opts?.source ?? ambient.source ?? cfg.defaultSource ?? \"theme\";\n const reason = opts?.reason;\n const severity: Severity = opts?.severity ?? getDefaultSeverity(reason);\n const fp = fingerprint(source, message, stack, reason);\n\n const userAgent =\n isBrowser() && typeof navigator !== \"undefined\" ? navigator.userAgent : \"\";\n\n return {\n source,\n repo: cfg.repo,\n severity,\n fingerprint: fp,\n message: message.slice(0, 500),\n stack: stack.slice(0, 10_000),\n context: { ...ambient, ...(opts?.context ?? {}) },\n url: safePageUrl(),\n userAgent,\n occurredAt: new Date().toISOString(),\n release: cfg.release,\n reason,\n };\n}\n\n/**\n * Capture an error and route it to the CMS.\n *\n * Browser path: throttle → batch → cross-origin POST (with reCAPTCHA token).\n * Server path: throttle → direct POST to Payload's REST API per event.\n *\n * Internal failures never throw — silently degraded so the logger can't break callers.\n *\n * `err` covers the realistic set of thrown values (Error, primitives). For the\n * rare case of throwing a plain object literal, callers should wrap in an Error\n * before passing — e.g. `logError(new Error(JSON.stringify(thing)))`.\n */\nexport function logError(\n err: Error | string | number | boolean | null | undefined,\n opts?: LogErrorOptions,\n): void {\n try {\n const event = buildEvent(err, opts);\n if (!event) return;\n if (!throttle.shouldEmit(event.fingerprint)) return;\n\n if (isBrowser()) {\n getBatcher().enqueue(event);\n } else {\n // Server path: synchronous-ish; we still fire-and-forget but don't batch.\n const cfg = _getConfig();\n if (!cfg) return;\n const apiKey = process.env.TENANT_API_KEY ?? process.env.PAYLOAD_PUBLIC_API_KEY;\n const baseUrl = process.env.PAYLOAD_PUBLIC_URL;\n if (!apiKey || !baseUrl) return;\n void postFromServer(baseUrl, apiKey, [event]).catch(() => {\n /* swallow — cascade guard */\n });\n }\n } catch {\n /* cascade guard: never throw from the logger itself */\n }\n}\n\n/**\n * Drain any queued (but not-yet-flushed) browser events right now. Called from\n * the `pagehide` global handler so a user closing the tab between time-based\n * flushes doesn't drop their queued errors.\n *\n * No-op when no batcher has been lazily initialized yet (nothing was queued).\n */\nexport function flushPending(): Promise<void> {\n if (!batcher) return Promise.resolve();\n return batcher.flushOnce();\n}\n\n/** @internal — test-only: reset batcher state between tests */\nexport function _resetForTests(): void {\n batcher = null;\n}\n"],"mappings":"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","names":["fingerprint","isBrowser","isBrowser"]}
@@ -0,0 +1,28 @@
1
+ import {
2
+ logError
3
+ } from "../chunk-EKIOMBUO.mjs";
4
+
5
+ // src/react/ErrorBoundary.tsx
6
+ import { Component } from "react";
7
+ var ErrorBoundary = class extends Component {
8
+ state = { hasError: false };
9
+ static getDerivedStateFromError() {
10
+ return { hasError: true };
11
+ }
12
+ componentDidCatch(error, errorInfo) {
13
+ logError(error, {
14
+ context: { componentStack: errorInfo.componentStack ?? "" }
15
+ });
16
+ this.props.onError?.(error, errorInfo);
17
+ }
18
+ render() {
19
+ if (this.state.hasError) {
20
+ return this.props.fallback ?? null;
21
+ }
22
+ return this.props.children;
23
+ }
24
+ };
25
+ export {
26
+ ErrorBoundary
27
+ };
28
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/ErrorBoundary.tsx"],"sourcesContent":["import { Component, type ErrorInfo, type ReactNode } from \"react\";\nimport { logError } from \"../logError\";\n\nexport interface ErrorBoundaryProps {\n children: ReactNode;\n /** Optional fallback to render when an error is caught. */\n fallback?: ReactNode;\n /** Optional callback invoked alongside logError. */\n onError?: (error: Error, errorInfo: ErrorInfo) => void;\n}\n\ninterface ErrorBoundaryState {\n hasError: boolean;\n}\n\n/**\n * React error boundary that forwards rendering errors to the centralized logger.\n *\n * Use at component-tree boundaries where you want to scope a render failure\n * (e.g., wrapping a widget so a render error there doesn't crash the host page).\n */\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n state: ErrorBoundaryState = { hasError: false };\n\n static getDerivedStateFromError(): ErrorBoundaryState {\n return { hasError: true };\n }\n\n componentDidCatch(error: Error, errorInfo: ErrorInfo): void {\n logError(error, {\n context: { componentStack: errorInfo.componentStack ?? \"\" },\n });\n this.props.onError?.(error, errorInfo);\n }\n\n render(): ReactNode {\n if (this.state.hasError) {\n return this.props.fallback ?? null;\n }\n return this.props.children;\n }\n}\n"],"mappings":";;;;;AAAA,SAAS,iBAAiD;AAqBnD,IAAM,gBAAN,cAA4B,UAAkD;AAAA,EACnF,QAA4B,EAAE,UAAU,MAAM;AAAA,EAE9C,OAAO,2BAA+C;AACpD,WAAO,EAAE,UAAU,KAAK;AAAA,EAC1B;AAAA,EAEA,kBAAkB,OAAc,WAA4B;AAC1D,aAAS,OAAO;AAAA,MACd,SAAS,EAAE,gBAAgB,UAAU,kBAAkB,GAAG;AAAA,IAC5D,CAAC;AACD,SAAK,MAAM,UAAU,OAAO,SAAS;AAAA,EACvC;AAAA,EAEA,SAAoB;AAClB,QAAI,KAAK,MAAM,UAAU;AACvB,aAAO,KAAK,MAAM,YAAY;AAAA,IAChC;AACA,WAAO,KAAK,MAAM;AAAA,EACpB;AACF;","names":[]}
@@ -0,0 +1,198 @@
1
+ type Severity = "error" | "warning" | "info";
2
+ type Source = "payload" | "theme" | "unit-table" | "reservation-app" | "rental-app" | "login";
3
+ type Repo = "gli-payload-multitenant" | "storage-theme-payload" | "unit-table" | "reservation-app" | "rental-app" | "npm-golocal-cloud-wrapper" | "golocal-cloud-wrapper";
4
+ /**
5
+ * Closed enum of failure-classification codes.
6
+ *
7
+ * Splits into two groups:
8
+ * 1. The 15 SE `errorTag` values shared with `@glidevvr/se-components` —
9
+ * keep these in lockstep with that library.
10
+ * 2. Internal codes for failures that aren't an SE-API call (silent empty
11
+ * results, build/scraper/hook errors, uncaught exceptions, etc.).
12
+ *
13
+ * Adding a new value here is intentional. If the failure mode you're logging
14
+ * doesn't fit any value, either add one to this list or use
15
+ * `"unhandled_exception"` (the deliberate "we don't know" bucket).
16
+ */
17
+ type Reason = "unit_not_found" | "unit_not_available" | "unit_unavailable" | "facility_not_found" | "invalid_tenant_credentials" | "unauthorized" | "session_expired" | "tenant_not_found" | "reservation_not_found" | "ledger_not_found" | "promotion_not_found" | "payment_failed" | "payment_declined" | "invalid_payment_option" | "validation_error" | "unit_list_empty" | "expected_data_missing" | "cache_stale" | "build_failure" | "hook_error" | "scraper_failure" | "data_integrity" | "unhandled_exception" | "network_timeout";
18
+ interface InitLoggerConfig {
19
+ /** Absolute URL to the Payload endpoint (e.g. https://payloadstorage.golocaldev.com/cms/api/log-error). */
20
+ endpoint: string;
21
+ /** Repo identifier — stamped on every event. */
22
+ repo: Repo;
23
+ /** Build identifier (git SHA / build ID) — stamped on every event. */
24
+ release?: string;
25
+ /** reCAPTCHA v3 site key (browser-only). If absent, browser-side logging is disabled. */
26
+ recaptchaSiteKey?: string;
27
+ /** reCAPTCHA action name; defaults to "log_error". */
28
+ recaptchaAction?: string;
29
+ /** Default source for events that don't pass an explicit source. */
30
+ defaultSource?: Source;
31
+ }
32
+ /**
33
+ * One line item in `LogContext.charges` / `LogContext.recurringFees`.
34
+ *
35
+ * Sub-keys are allowlisted: `id` / `name` / `amount` / `cycle`. Anything else
36
+ * (memo, note, internal_comment, etc.) is stripped by the server-side
37
+ * `sanitizeIssueEventContext` hook before persistence.
38
+ */
39
+ interface LogChargeLine {
40
+ id?: string;
41
+ name?: string;
42
+ amount?: number;
43
+ /** Only meaningful on `recurringFees`, e.g. "monthly". */
44
+ cycle?: string;
45
+ }
46
+ /**
47
+ * Closed allowlist for log context fields.
48
+ *
49
+ * **Why this is a closed type (no index signature).** The PII contract for
50
+ * this logger is "never accept user-input data into the pipeline in the first
51
+ * place" — not "scrub it after". A closed type makes TypeScript reject unknown
52
+ * keys at every call site, so card numbers, names, emails, addresses, etc.
53
+ * cannot accidentally land in an event's `context`.
54
+ *
55
+ * Naming convention:
56
+ * - `se*` prefix → identifier from the SE API (matches SE's wire term)
57
+ * - `vendor*` prefix → identifier from the underlying property-management
58
+ * system (SiteLink / StorEdge / DoorSwap)
59
+ * - everything else → user-facing terms shared with the admin UI
60
+ * (e.g. `organizationId` matches the "Organization" label used in CMS)
61
+ *
62
+ * If a new context field is genuinely needed, add it here intentionally and
63
+ * confirm it carries no PII. The server has a defense-in-depth filter
64
+ * (`sanitizeIssueEventContext`) that drops unknown keys before persistence.
65
+ */
66
+ interface LogContext {
67
+ /** Default source for events that don't pass an explicit source. */
68
+ source?: Source;
69
+ /** Anonymous browser session identifier. NEVER a logged-in user identity. */
70
+ sessionId?: string;
71
+ /** React component-tree path from `<ErrorBoundary>`. No user input. */
72
+ componentStack?: string;
73
+ /** Payload-side ID for the org (Mongo ObjectID). Maps 1:1 with the SE
74
+ * company below — they're two IDs for the same entity in different systems. */
75
+ organizationId?: string;
76
+ /** Display name, e.g. "Go Local Interactive". */
77
+ organizationName?: string;
78
+ /** SE-side numeric ID for the same org (e.g. 7). Used to pivot into SE admin. */
79
+ seCompanyId?: number;
80
+ /** SE renter UUID. SE calls this "tenant" — confusing in our multi-tenant
81
+ * CMS context, hence the `se` prefix. */
82
+ seTenantId?: string;
83
+ /** Vendor-system renter ID (SiteLink/StorEdge/DoorSwap). */
84
+ vendorTenantId?: string;
85
+ /** Which underlying property-management system the org uses. */
86
+ vendorType?: "SiteLink" | "StorEdge" | "DoorSwap";
87
+ seFacilityId?: string | number;
88
+ vendorFacilityId?: string | number;
89
+ /** Vendor-side site/region code. */
90
+ siteLocationCode?: string;
91
+ seUnitId?: string | number;
92
+ vendorUnitId?: string | number;
93
+ /** Unit category slug (e.g. "5x10-storage"). */
94
+ unitCategorySlug?: string;
95
+ /** Pricing tier tag, e.g. "Tier 1" / "Tier 2". */
96
+ unitTier?: string;
97
+ /** Rate plan family, e.g. "standard_rate" | "tiered_rate" | "channel_rate" | "managed_rate". */
98
+ ratePlan?: string;
99
+ seReservationId?: string;
100
+ vendorReservationId?: string | number;
101
+ /** Hard hold (units locked) vs soft inquiry. */
102
+ reservationType?: "hard" | "soft";
103
+ /** SE-side UUID on the `vendor_rentals` table. */
104
+ seRentalId?: string;
105
+ /** Vendor-system rental ID (e.g. "R-1234567"). */
106
+ vendorRentalId?: string;
107
+ /** SE move-in event / transaction ID. */
108
+ seTransactionId?: string | number;
109
+ /** Vendor's ledger ID, surfaced via SE's `_se_ledger_id`. */
110
+ seLedgerId?: string | number;
111
+ /** Payment receipt ID (SE `_se_receipt_id`). */
112
+ seReceiptId?: string | number;
113
+ /** Single charge ID when one specific line item is the focus of the error. */
114
+ chargeId?: string;
115
+ /** Current invoice / one-time charges. Sub-keys are allowlisted by
116
+ * `sanitizeIssueEventContext` — memo/note/comment fields are stripped. */
117
+ charges?: LogChargeLine[];
118
+ /** Recurring billing schedule. Same sub-key allowlist as `charges`. */
119
+ recurringFees?: LogChargeLine[];
120
+ promotionId?: string | number;
121
+ promotionSlug?: string;
122
+ insurancePlanId?: string | number;
123
+ paymentMethodType?: "cc" | "ach";
124
+ autopayEnabled?: boolean;
125
+ billingCycle?: "monthly" | "prepay";
126
+ /** ISO date the user picked. The date itself is not PII — it's a calendar
127
+ * click, not who-they-are. */
128
+ moveInDate?: string;
129
+ /** Numeric rate the user *saw* on the page. Useful for "user saw $X but
130
+ * was charged $Y" debugging without logging payment data. */
131
+ quotedRate?: number;
132
+ /** Which multi-step user flow the error occurred in. Pair with `step` —
133
+ * `step` is meaningless without a flow reference (step 3 of *what*?).
134
+ * Closed union so values stay grep-able across apps. */
135
+ flow?: "reservation" | "rental" | "cancellation" | "payment-update" | "onboarding";
136
+ /** Position within `flow` — number for ordered flows, slug for named ones.
137
+ * Meaningful only when paired with `flow`. Different from `slug` (which
138
+ * entity is in focus) and from the top-level `url` (where the user was). */
139
+ step?: number | string;
140
+ /** Stable identifier for the entity being acted on — page slug, market
141
+ * slug, unit-category slug, etc. Decoupled from URL: a build-time SSG
142
+ * error has no URL yet but the slug for the page being built does. */
143
+ slug?: string;
144
+ /** Which API/RPC was being called (e.g. "createReservationV2"). */
145
+ apiMethod?: string;
146
+ /** URL of the called endpoint, query string already stripped. */
147
+ apiEndpoint?: string;
148
+ httpStatus?: number;
149
+ /** Cache identifier when the error came from a stale-cache lookup. */
150
+ cacheKey?: string;
151
+ /** Mirrors `LogErrorOptions.reason` for filter convenience. */
152
+ errorTag?: string;
153
+ /** Closed schema field names, never user-typed values. e.g. `["cvv", "email"]`. */
154
+ validationFieldNames?: string[];
155
+ /** Soft-assertion expected count, e.g. "should have at least 1 unit, got 0". */
156
+ expectedCount?: number;
157
+ actualCount?: number;
158
+ collectionSlug?: string;
159
+ documentId?: string;
160
+ /** Which Payload hook fired the error (e.g. "beforeChange"). */
161
+ hookName?: string;
162
+ userRole?: "admin" | "super-admin" | "user" | "api-user";
163
+ /** AI-scraper batch ID. */
164
+ scraperBatchId?: string;
165
+ /** Build/deploy ID for build-trigger hooks. */
166
+ buildId?: string;
167
+ }
168
+ interface LogErrorOptions {
169
+ source?: Source;
170
+ severity?: Severity;
171
+ context?: LogContext;
172
+ /**
173
+ * Failure-classification code — the WHY behind the error. Closed enum
174
+ * (see `Reason`). Same code path failing for different reasons becomes
175
+ * distinct Issues, since `reason` is part of the fingerprint.
176
+ *
177
+ * If omitted, the event is uncaught/unclassified — fingerprint groups
178
+ * by `(source, message, top-stack-frame)` only.
179
+ */
180
+ reason?: Reason;
181
+ }
182
+ interface LogEvent {
183
+ source: Source;
184
+ repo: Repo;
185
+ severity: Severity;
186
+ fingerprint: string;
187
+ message: string;
188
+ stack: string;
189
+ context: LogContext;
190
+ url: string;
191
+ userAgent: string;
192
+ occurredAt: string;
193
+ release?: string;
194
+ /** Failure-classification code copied from `LogErrorOptions.reason`. */
195
+ reason?: Reason;
196
+ }
197
+
198
+ export type { InitLoggerConfig as I, LogContext as L, Reason as R, Severity as S, LogErrorOptions as a, LogChargeLine as b, LogEvent as c, Repo as d, Source as e };