loguard-sdk 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,181 @@
1
- # Temporary Holding Version
1
+ # loguard-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ JavaScript and TypeScript telemetry for browser applications and Node servers.
4
+ Supports structured logs, page views, explicit actions, exceptions, HTTP outcomes,
5
+ and correlated action timing through Loguard's existing `POST /v1/logs` API.
6
+
7
+ Requires Node 22+ on the server and a modern browser supporting Fetch,
8
+ AbortController, and Web Crypto. No React or Next.js dependency is required.
9
+
10
+ ```sh
11
+ npm install loguard-sdk
12
+ ```
13
+
14
+ ## Browser
15
+
16
+ Initialize once after the browser application mounts. Imports are SSR-safe;
17
+ calling `createBrowserTelemetry` during SSR is intentionally rejected.
18
+
19
+ ```ts
20
+ import { createBrowserTelemetry } from 'loguard-sdk';
21
+
22
+ const telemetry = createBrowserTelemetry({
23
+ collectorUrl: '/api/telemetry',
24
+ service: 'deel-browser',
25
+ environment: 'production',
26
+ release: '2026.10.15',
27
+ capture: { pageViews: true, errors: true, clicks: true },
28
+ });
29
+
30
+ telemetry.action('invoice.preview.opened');
31
+ telemetry.page('/invoices/:id'); // Use when automatic page capture is disabled.
32
+ telemetry.captureException(new Error('Invoice preview failed'));
33
+
34
+ // During application teardown:
35
+ await telemetry.shutdown();
36
+ ```
37
+
38
+ Automatic capture is opt-in. Click capture only observes elements with an
39
+ explicit stable name; it never scrapes text, form values, or input contents:
40
+
41
+ ```html
42
+ <button data-loguard-action="invoice.save.clicked">Save invoice</button>
43
+ ```
44
+
45
+ Page capture follows `pushState`, `replaceState`, and `popstate`. Queries and
46
+ fragments are removed; numeric IDs, UUIDs, email segments, and reset tokens are
47
+ normalized. Supply `normalizeRoute(pathname)` for application-specific routes
48
+ that contain names or other private data. Do not enable both automatic and manual
49
+ page tracking for the same navigation.
50
+
51
+ The SDK uses a random in-memory session ID for each instance, not an authentication
52
+ cookie or persistent tracking identifier. Browser user IDs are discarded; the
53
+ collector derives identity from the application's authenticated session.
54
+
55
+ ## Server
56
+
57
+ Keep the ingestion key only in the server's environment. Use your deployed
58
+ ingestion endpoint, including `/v1/logs`; there is no assumed public hostname.
59
+
60
+ ```ts
61
+ import { createServerTelemetry } from 'loguard-sdk/server';
62
+
63
+ const telemetry = createServerTelemetry({
64
+ ingestUrl: process.env.LOGUARD_INGEST_URL!,
65
+ apiKey: process.env.LOGUARD_API_KEY!,
66
+ service: 'deel-api',
67
+ environment: 'production',
68
+ });
69
+
70
+ const invoice = await telemetry.measureAction(
71
+ 'invoice.create',
72
+ () => persistInvoice(),
73
+ { operationId: requestOperationId, userId: authenticatedUser.id },
74
+ );
75
+ // Emits invoice.create.started and invoice.create.completed/failed.
76
+ // The original result or thrown error is preserved.
77
+
78
+ telemetry.request('/api/invoices/:id', {
79
+ method: 'POST', statusCode: 201, durationMs: 142,
80
+ });
81
+ await telemetry.flush();
82
+ ```
83
+
84
+ `measureAction` considers a resolved operation successful: wrap the committed
85
+ operation, not an earlier button click or a function that resolves with an error
86
+ tuple. Provider acceptance is distinct from eventual email/payment delivery.
87
+
88
+ Server clients do not create background timers. In serverless handlers, await
89
+ `flush()` or register that promise with the hosting platform's supported
90
+ post-response lifecycle mechanism. A floating `void flush()` is not a durable
91
+ delivery guarantee. Long-lived servers should call `shutdown()` on termination.
92
+
93
+ ## Browser Collector: Next.js App Router
94
+
95
+ ```ts
96
+ // app/api/telemetry/route.ts
97
+ import { createCollector } from 'loguard-sdk/server';
98
+ import { auth } from '@/auth'; // Your existing session implementation.
99
+
100
+ export const POST = createCollector({
101
+ ingestUrl: process.env.LOGUARD_INGEST_URL!,
102
+ apiKey: process.env.LOGUARD_API_KEY!,
103
+ service: 'deel-browser',
104
+ environment: 'production',
105
+ allowedEvents: ['page.viewed', 'application.exception', 'invoice.save.clicked'],
106
+ authorize: async () => {
107
+ const session = await auth();
108
+ return session?.user?.id ? { userId: session.user.id } : null;
109
+ },
110
+ });
111
+ ```
112
+
113
+ Only the session callback and event allowlist belong to the host app. The SDK
114
+ handles body limits, contract validation, sanitization, trusted context,
115
+ forwarding, timeouts, and partial acknowledgements. The auth import above is
116
+ application-owned; it is not provided by this package.
117
+
118
+ ## Browser Collector: Node / Next.js Pages Router
119
+
120
+ ```ts
121
+ // pages/api/telemetry.ts
122
+ import { createNodeCollector } from 'loguard-sdk/node';
123
+ import { getSession } from '@/session'; // Your existing session implementation.
124
+
125
+ export const config = { api: { bodyParser: false } };
126
+
127
+ export default createNodeCollector({
128
+ publicOrigin: 'https://your-application.example',
129
+ ingestUrl: process.env.LOGUARD_INGEST_URL!,
130
+ apiKey: process.env.LOGUARD_API_KEY!,
131
+ service: 'deel-browser',
132
+ environment: 'production',
133
+ allowedEvents: ['page.viewed', 'application.exception', 'invoice.save.clicked'],
134
+ authorize: async (req, res) => {
135
+ const session = await getSession(req, res);
136
+ return session?.user?.id ? { userId: session.user.id } : null;
137
+ },
138
+ });
139
+ ```
140
+
141
+ `publicOrigin` is explicit so forwarded Host headers cannot determine origin
142
+ policy. Use `bodyParser: false` to enforce SDK byte limits while streaming. The
143
+ adapter also accepts already-parsed JSON, but any earlier parsing limits are then
144
+ the host's responsibility.
145
+
146
+ Unauthenticated login/signup pages require a separate explicit public policy,
147
+ for example `authorize: () => ({})`, with a small event allowlist and rate limiting.
148
+ Use the optional `rateLimit(request)` callback or your existing platform limiter.
149
+ An Origin check is not authentication and cannot prevent requests from scripts.
150
+
151
+ ## Script Include
152
+
153
+ The npm artifact also includes `dist/loguard.global.js`, a standalone browser
154
+ bundle. Self-host this file or serve it from a package CDN pinned to the installed
155
+ version, then initialize it after loading:
156
+
157
+ ```html
158
+ <script src="/vendor/loguard.global.js"></script>
159
+ <script>
160
+ const telemetry = LoguardTelemetry.createBrowserTelemetry({
161
+ collectorUrl: '/api/telemetry',
162
+ service: 'website',
163
+ environment: 'production',
164
+ capture: { pageViews: true, clicks: true, errors: true }
165
+ });
166
+ </script>
167
+ ```
168
+
169
+ Keep the API key out of HTML, browser options, and `NEXT_PUBLIC_`/`VITE_` variables.
170
+ The browser client only accepts a same-origin collector URL.
171
+
172
+ ## Documentation
173
+
174
+ - [API reference](docs/api.md)
175
+ - [Event contract and versioning](docs/contracts.md)
176
+ - [Delivery, security, and operational limits](docs/operations.md)
177
+
178
+ This is an initial `0.1.0` release. It does not implement a durable browser outbox,
179
+ anomaly detection, or exactly-once storage. SDK acceptance means queued locally;
180
+ an ingestion acknowledgement means durable queue acceptance, not completed
181
+ processing or a successful user operation.
@@ -0,0 +1,308 @@
1
+ import {
2
+ API_VERSION,
3
+ EVENT_SCHEMA_VERSION,
4
+ MAX_BATCH_BYTES,
5
+ MAX_BATCH_EVENTS,
6
+ MAX_EVENT_BYTES,
7
+ SDK_VERSION,
8
+ acknowledgementSchema,
9
+ eventSchema
10
+ } from "./chunk-ISRLGH43.js";
11
+
12
+ // src/privacy.ts
13
+ var forbidden = /password|passwd|secret|token|cookie|authorization|api[_-]?key|^email$|^organization_?id$|^app_?id$/i;
14
+ function redactText(text) {
15
+ return text.replace(/\/reset\/[^\s/?#]+/gi, "/reset/:token").replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted]").replace(
16
+ /\b(?:password|secret|token|authorization|api[_-]?key)\s*[:=]\s*(?:bearer\s+)?[^\s,;}]+/gi,
17
+ "[redacted]"
18
+ ).replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted email]");
19
+ }
20
+ function sanitize(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
21
+ if (depth > 8) throw new Error("Metadata nesting exceeds the SDK limit");
22
+ if (value === null || typeof value === "boolean") return value;
23
+ if (typeof value === "string") return redactText(value);
24
+ if (typeof value === "number" && Number.isFinite(value)) return value;
25
+ if (!value || typeof value !== "object" || seen.has(value))
26
+ throw new Error("Metadata must be finite, acyclic JSON");
27
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
28
+ throw new Error("Metadata must contain plain objects");
29
+ seen.add(value);
30
+ try {
31
+ if (Array.isArray(value)) {
32
+ if (value.length > 100) throw new Error("Metadata array exceeds the SDK limit");
33
+ return value.map((item) => sanitize(item, depth + 1, seen));
34
+ }
35
+ const entries = Object.entries(value);
36
+ if (entries.length > 64) throw new Error("Metadata object exceeds the SDK limit");
37
+ return Object.fromEntries(
38
+ entries.filter(([, item]) => item !== void 0).map(([key, item]) => [
39
+ key,
40
+ forbidden.test(key) ? "[redacted]" : sanitize(item, depth + 1, seen)
41
+ ])
42
+ );
43
+ } finally {
44
+ seen.delete(value);
45
+ }
46
+ }
47
+ function normalizeRoute(value) {
48
+ const url = new URL(value, "https://route.invalid");
49
+ return url.pathname.split("/").map((part, index, parts) => {
50
+ if (index > 1 && parts[index - 1] === "reset") return ":token";
51
+ if (/^\d+$/.test(part) || /^[a-f0-9]{8}-[a-f0-9-]{27,}$/i.test(part) || /%40|@/i.test(part) || part.length > 48)
52
+ return ":id";
53
+ return part;
54
+ }).join("/");
55
+ }
56
+
57
+ // src/client.ts
58
+ function encodeBatch(events) {
59
+ return JSON.stringify({
60
+ apiVersion: API_VERSION,
61
+ eventSchemaVersion: EVENT_SCHEMA_VERSION,
62
+ events
63
+ });
64
+ }
65
+ function byteLength(value) {
66
+ return new TextEncoder().encode(value).byteLength;
67
+ }
68
+ var TelemetryClient = class {
69
+ queue = [];
70
+ inFlight = null;
71
+ closed = false;
72
+ totalAccepted = 0;
73
+ totalDropped = 0;
74
+ notifying = false;
75
+ options;
76
+ delivery;
77
+ constructor(options, delivery) {
78
+ if (!options.service?.trim() || !["development", "staging", "production"].includes(options.environment))
79
+ throw new Error("A service and valid environment are required");
80
+ for (const value of [
81
+ options.maxBufferEvents ?? 500,
82
+ options.maxAttempts ?? 3,
83
+ options.timeoutMs ?? 3e3
84
+ ]) {
85
+ if (!Number.isInteger(value) || value < 1)
86
+ throw new Error("SDK limits must be positive integers");
87
+ }
88
+ if (!Number.isFinite(options.retryBaseMs ?? 1e3) || (options.retryBaseMs ?? 1e3) < 0)
89
+ throw new Error("Retry delay must be nonnegative");
90
+ this.options = { ...options };
91
+ this.delivery = delivery;
92
+ }
93
+ diagnostic(code, count = 1) {
94
+ if (this.notifying) return;
95
+ this.notifying = true;
96
+ try {
97
+ this.options.onDiagnostic?.({ code, count });
98
+ } catch {
99
+ } finally {
100
+ this.notifying = false;
101
+ }
102
+ }
103
+ track(input) {
104
+ if (this.closed || this.options.enabled === false) return false;
105
+ if (this.queue.length >= (this.options.maxBufferEvents ?? 500)) {
106
+ this.diagnostic("buffer_full");
107
+ this.totalDropped++;
108
+ return false;
109
+ }
110
+ try {
111
+ const result = eventSchema.safeParse({
112
+ ...input,
113
+ eventSchemaVersion: EVENT_SCHEMA_VERSION,
114
+ eventId: input.eventId ?? crypto.randomUUID(),
115
+ timestamp: input.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
116
+ level: input.level ?? "info",
117
+ message: redactText(input.message ?? input.name),
118
+ environment: this.options.environment,
119
+ service: this.options.service,
120
+ source: this.delivery.source,
121
+ release: this.options.release,
122
+ ...input.endpoint ? { endpoint: normalizeRoute(input.endpoint) } : {},
123
+ ...input.metadata ? { metadata: sanitize(input.metadata) } : {}
124
+ });
125
+ if (!result.success || byteLength(JSON.stringify(result.data)) > MAX_EVENT_BYTES)
126
+ throw new Error("Invalid telemetry event");
127
+ if (this.queue.some((item) => item.event.eventId === result.data.eventId)) return false;
128
+ this.queue.push({ event: result.data, attempts: 0, nextAt: 0 });
129
+ return true;
130
+ } catch {
131
+ this.totalDropped++;
132
+ this.diagnostic("invalid_event");
133
+ return false;
134
+ }
135
+ }
136
+ log(message, details = {}) {
137
+ return this.track({ ...details, name: "application.log", eventType: "log", message });
138
+ }
139
+ page(route, details = {}) {
140
+ return this.track({ ...details, name: "page.viewed", eventType: "page_view", endpoint: route });
141
+ }
142
+ action(name, details = {}) {
143
+ return this.track({ ...details, name, eventType: "action" });
144
+ }
145
+ request(endpoint, details) {
146
+ return this.track({
147
+ ...details,
148
+ endpoint,
149
+ name: "http.request.completed",
150
+ eventType: "request",
151
+ level: details.level ?? (details.statusCode >= 500 ? "error" : details.statusCode >= 400 ? "warn" : "info")
152
+ });
153
+ }
154
+ async measureAction(name, operation, details = {}) {
155
+ if (this.closed || this.options.enabled === false) return operation();
156
+ let operationId;
157
+ try {
158
+ operationId = details.operationId ?? crypto.randomUUID();
159
+ } catch {
160
+ return operation();
161
+ }
162
+ const start = performance.now();
163
+ this.action(`${name}.started`, { ...details, operationId });
164
+ try {
165
+ const result = await operation();
166
+ this.action(`${name}.completed`, {
167
+ ...details,
168
+ operationId,
169
+ outcome: "success",
170
+ durationMs: performance.now() - start
171
+ });
172
+ return result;
173
+ } catch (error) {
174
+ this.action(`${name}.failed`, {
175
+ ...details,
176
+ operationId,
177
+ outcome: "failure",
178
+ level: "error",
179
+ durationMs: performance.now() - start
180
+ });
181
+ throw error;
182
+ }
183
+ }
184
+ captureException(error, details = {}) {
185
+ return this.track({
186
+ ...details,
187
+ name: "application.exception",
188
+ eventType: "exception",
189
+ level: "error",
190
+ message: error instanceof Error ? error.message : "Unknown application error",
191
+ metadata: {
192
+ ...details.metadata,
193
+ errorName: error instanceof Error ? error.name : "Error",
194
+ stack: error instanceof Error ? error.stack ?? "" : ""
195
+ }
196
+ });
197
+ }
198
+ stats() {
199
+ return { accepted: this.totalAccepted, dropped: this.totalDropped, pending: this.queue.length };
200
+ }
201
+ flush() {
202
+ if (this.inFlight) return this.inFlight;
203
+ this.inFlight = this.deliver().finally(() => {
204
+ this.inFlight = null;
205
+ });
206
+ return this.inFlight;
207
+ }
208
+ async deliver() {
209
+ while (true) {
210
+ const batch = [];
211
+ for (const item of this.queue) {
212
+ if (item.nextAt > Date.now()) continue;
213
+ if (batch.length === MAX_BATCH_EVENTS || byteLength(encodeBatch([...batch.map((x) => x.event), item.event])) > MAX_BATCH_BYTES)
214
+ break;
215
+ batch.push(item);
216
+ }
217
+ if (!batch.length) return this.stats();
218
+ batch.forEach((item) => item.attempts++);
219
+ let retryAfter = 0;
220
+ const accepted = /* @__PURE__ */ new Set();
221
+ const rejected = /* @__PURE__ */ new Set();
222
+ const controller = new AbortController();
223
+ const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 3e3);
224
+ try {
225
+ const response = await (this.options.fetch ?? globalThis.fetch)(this.delivery.endpoint, {
226
+ method: "POST",
227
+ credentials: this.delivery.source === "browser" ? "same-origin" : "omit",
228
+ headers: {
229
+ "Content-Type": "application/json",
230
+ "X-Loguard-SDK-Version": SDK_VERSION,
231
+ "X-Loguard-Event-Schema": String(EVENT_SCHEMA_VERSION),
232
+ ...this.delivery.headers
233
+ },
234
+ body: encodeBatch(batch.map((item) => item.event)),
235
+ signal: controller.signal,
236
+ keepalive: this.delivery.keepalive
237
+ });
238
+ const retryHeader = response.headers.get("retry-after");
239
+ if (retryHeader)
240
+ retryAfter = Math.min(
241
+ 6e4,
242
+ Math.max(
243
+ 0,
244
+ /^\d+$/.test(retryHeader) ? Number(retryHeader) * 1e3 : Date.parse(retryHeader) - Date.now()
245
+ ) || 0
246
+ );
247
+ if (response.status === 202) {
248
+ const ack = acknowledgementSchema.safeParse(await response.json());
249
+ if (!ack.success) throw new Error("Invalid acknowledgement");
250
+ ack.data.accepted.forEach((id) => accepted.add(id));
251
+ ack.data.rejected.filter((item) => item.code !== "queue_rejected").forEach((item) => {
252
+ if (item.eventId) rejected.add(item.eventId);
253
+ });
254
+ } else if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
255
+ batch.forEach((item) => rejected.add(item.event.eventId));
256
+ }
257
+ } catch {
258
+ this.diagnostic("transport_error", batch.length);
259
+ } finally {
260
+ clearTimeout(timer);
261
+ }
262
+ const complete = /* @__PURE__ */ new Set();
263
+ let retry = false;
264
+ for (const item of batch) {
265
+ if (accepted.has(item.event.eventId)) {
266
+ this.totalAccepted++;
267
+ complete.add(item);
268
+ } else if (rejected.has(item.event.eventId) || item.attempts >= (this.options.maxAttempts ?? 3)) {
269
+ this.totalDropped++;
270
+ complete.add(item);
271
+ this.diagnostic("dropped");
272
+ } else {
273
+ retry = true;
274
+ const delay = Math.min(
275
+ 3e4,
276
+ (this.options.retryBaseMs ?? 1e3) * 2 ** (item.attempts - 1)
277
+ );
278
+ item.nextAt = Date.now() + Math.max(retryAfter, delay * (0.5 + Math.random()));
279
+ }
280
+ }
281
+ this.queue = this.queue.filter((item) => !complete.has(item));
282
+ if (retry) return this.stats();
283
+ }
284
+ }
285
+ async shutdown(timeoutMs = 3e3) {
286
+ this.closed = true;
287
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
288
+ throw new Error("Shutdown timeout must be nonnegative");
289
+ const deadline = Date.now() + timeoutMs;
290
+ do {
291
+ await this.flush();
292
+ if (!this.queue.length || Date.now() >= deadline) break;
293
+ await new Promise(
294
+ (resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now())))
295
+ );
296
+ } while (Date.now() < deadline);
297
+ return this.stats();
298
+ }
299
+ };
300
+
301
+ export {
302
+ redactText,
303
+ sanitize,
304
+ normalizeRoute,
305
+ encodeBatch,
306
+ byteLength,
307
+ TelemetryClient
308
+ };
@@ -0,0 +1,56 @@
1
+ // src/contracts.ts
2
+ import * as z from "zod";
3
+ var SDK_VERSION = "0.1.0";
4
+ var API_VERSION = "v1";
5
+ var EVENT_SCHEMA_VERSION = 1;
6
+ var MAX_BATCH_EVENTS = 100;
7
+ var MAX_BATCH_BYTES = 60 * 1024;
8
+ var MAX_EVENT_BYTES = 16 * 1024;
9
+ var identifier = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
10
+ var eventSchema = z.strictObject({
11
+ eventSchemaVersion: z.literal(EVENT_SCHEMA_VERSION),
12
+ eventId: identifier,
13
+ timestamp: z.iso.datetime({ offset: true }),
14
+ level: z.enum(["debug", "info", "warn", "error", "critical"]),
15
+ message: z.string().min(1).max(2048),
16
+ environment: z.enum(["development", "staging", "production"]),
17
+ service: z.string().min(1).max(100),
18
+ source: z.enum(["browser", "server"]),
19
+ eventType: z.enum(["log", "page_view", "action", "request", "exception", "heartbeat"]),
20
+ name: z.string().regex(/^[a-z][a-z0-9_.-]{0,99}$/),
21
+ outcome: z.enum(["success", "failure", "cancelled", "rejected"]).optional(),
22
+ endpoint: z.string().max(512).optional(),
23
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).optional(),
24
+ statusCode: z.number().int().min(100).max(599).optional(),
25
+ durationMs: z.number().finite().nonnegative().optional(),
26
+ operationId: identifier.optional(),
27
+ requestId: identifier.optional(),
28
+ traceId: z.string().regex(/^[0-9a-f]{32}$/).optional(),
29
+ release: z.string().max(128).optional(),
30
+ userId: z.string().min(1).max(128).optional(),
31
+ sessionId: z.string().min(1).max(128).optional(),
32
+ metadata: z.record(z.string().max(100), z.json()).optional()
33
+ });
34
+ var batchSchema = z.strictObject({
35
+ apiVersion: z.literal(API_VERSION),
36
+ eventSchemaVersion: z.literal(EVENT_SCHEMA_VERSION),
37
+ events: z.array(eventSchema).min(1).max(MAX_BATCH_EVENTS)
38
+ });
39
+ var acknowledgementSchema = z.object({
40
+ accepted: z.array(identifier),
41
+ rejected: z.array(
42
+ z.object({ eventId: identifier.optional(), code: z.string(), message: z.string().optional() })
43
+ )
44
+ });
45
+
46
+ export {
47
+ SDK_VERSION,
48
+ API_VERSION,
49
+ EVENT_SCHEMA_VERSION,
50
+ MAX_BATCH_EVENTS,
51
+ MAX_BATCH_BYTES,
52
+ MAX_EVENT_BYTES,
53
+ eventSchema,
54
+ batchSchema,
55
+ acknowledgementSchema
56
+ };