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 +180 -2
- package/dist/chunk-4UI4EGMM.js +308 -0
- package/dist/chunk-ISRLGH43.js +56 -0
- package/dist/chunk-MBRWZ2NW.js +176 -0
- package/dist/client-DJYLLjEG.d.cts +54 -0
- package/dist/client-ECTcXoxF.d.ts +54 -0
- package/dist/contracts.cjs +98 -0
- package/dist/contracts.d.cts +140 -0
- package/dist/contracts.d.ts +140 -0
- package/dist/contracts.js +22 -0
- package/dist/index.cjs +477 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +113 -0
- package/dist/loguard.global.js +57 -0
- package/dist/node.cjs +329 -0
- package/dist/node.d.cts +17 -0
- package/dist/node.d.ts +17 -0
- package/dist/node.js +54 -0
- package/dist/schema/v1.json +209 -0
- package/dist/server.cjs +529 -0
- package/dist/server.d.cts +24 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +12 -0
- package/docs/api.md +82 -0
- package/docs/contracts.md +88 -0
- package/docs/operations.md +58 -0
- package/package.json +80 -4
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
|
-
#
|
|
1
|
+
# loguard-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
};
|