kmind-apm-web 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/dist/index.cjs ADDED
@@ -0,0 +1,490 @@
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 key3 of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key3) && key3 !== except)
14
+ __defProp(to, key3, { get: () => from[key3], enumerable: !(desc = __getOwnPropDesc(from, key3)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ getActiveTraceId: () => getActiveTraceId,
24
+ init: () => init,
25
+ logger: () => logger,
26
+ recordMetric: () => recordMetric
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/config.ts
31
+ function resolveConfig(input) {
32
+ if (!input.serviceName.trim()) throw new Error("serviceName is required");
33
+ if (!input.clientKey.trim()) throw new Error("clientKey is required");
34
+ const sampleRate = input.sampleRate ?? 1;
35
+ if (sampleRate < 0 || sampleRate > 1) throw new Error("sampleRate must be between 0 and 1");
36
+ return {
37
+ serviceName: input.serviceName.trim(),
38
+ clientKey: input.clientKey.trim(),
39
+ enabled: input.enabled ?? true,
40
+ sampleRate,
41
+ debugHttp: input.debugHttp ?? false,
42
+ debugTrace: input.debugTrace ?? false,
43
+ captureConsole: input.captureConsole ?? false,
44
+ propagateTraceTo: input.propagateTraceTo ?? [],
45
+ endpoint: (input.endpoint ?? "https://ingest.kmind.com.br/v1").replace(/\/$/, ""),
46
+ statusEndpoint: input.statusEndpoint ?? "https://ingest.kmind.com.br/v1/apm/status",
47
+ scrub: input.scrub ?? ((value) => value),
48
+ privacy: { redactAttributeKeys: input.privacy?.redactAttributeKeys ?? ["token", "email", "cpf", "password", "authorization", "cookie"], redactUrlQueryKeys: input.privacy?.redactUrlQueryKeys ?? ["token", "email", "cpf", "password"] },
49
+ slowTraceThresholdMs: input.slowTraceThresholdMs ?? 1e3
50
+ };
51
+ }
52
+
53
+ // src/utils.ts
54
+ function safeRun(fn, debug = false) {
55
+ try {
56
+ fn();
57
+ } catch (error) {
58
+ if (debug) console.debug("[Kmind APM]", error);
59
+ }
60
+ }
61
+ function nanoNow() {
62
+ return String(BigInt(Date.now()) * 1000000n);
63
+ }
64
+ function randomHex(bytes) {
65
+ const data = new Uint8Array(bytes);
66
+ crypto.getRandomValues(data);
67
+ return Array.from(data, (byte) => byte.toString(16).padStart(2, "0")).join("");
68
+ }
69
+ function attributes(values) {
70
+ return Object.entries(values).filter(([, value]) => value !== void 0 && value !== null).map(([key3, value]) => ({ key: key3, value: { stringValue: String(value) } }));
71
+ }
72
+
73
+ // src/session.ts
74
+ var key = "kmind.apm.session.id";
75
+ function sessionId() {
76
+ try {
77
+ const value = sessionStorage.getItem(key);
78
+ if (value) return value;
79
+ const next = randomHex(16);
80
+ sessionStorage.setItem(key, next);
81
+ return next;
82
+ } catch {
83
+ return randomHex(16);
84
+ }
85
+ }
86
+
87
+ // src/otlp.ts
88
+ var resource = (serviceName) => ({ attributes: attributes({ "service.name": serviceName, "session.id": sessionId(), "telemetry.sdk.name": "kmind-apm-web" }) });
89
+ function tracePayload(serviceName, span) {
90
+ return { resourceSpans: [{ resource: resource(serviceName), scopeSpans: [{ scope: { name: "kmind-apm-web" }, spans: [{ traceId: span.traceId, spanId: span.spanId, parentSpanId: span.parentSpanId, name: span.name, kind: 3, startTimeUnixNano: span.start, endTimeUnixNano: span.end ?? nanoNow(), attributes: attributes(span.attrs), status: span.status === "ERROR" ? { code: 2 } : { code: 1 } }] }] }] };
91
+ }
92
+ function metricPayload(serviceName, name, value, attrs) {
93
+ return { resourceMetrics: [{ resource: resource(serviceName), scopeMetrics: [{ scope: { name: "kmind-apm-web" }, metrics: [{ name, unit: "ms", gauge: { dataPoints: [{ asDouble: value, timeUnixNano: nanoNow(), attributes: attributes({ "url.path": location.pathname, "session.id": sessionId(), ...attrs }) }] } }] }] }] };
94
+ }
95
+ function logPayload(serviceName, severity, message2, attrs) {
96
+ return { resourceLogs: [{ resource: resource(serviceName), scopeLogs: [{ scope: { name: "kmind-apm-web" }, logRecords: [{ timeUnixNano: nanoNow(), severityText: severity, body: { stringValue: message2 }, attributes: attributes({ "session.id": sessionId(), ...attrs }) }] }] }] };
97
+ }
98
+
99
+ // src/privacy.ts
100
+ var config;
101
+ var defaultKeys = ["token", "email", "cpf", "password"];
102
+ function configurePrivacy(next) {
103
+ config = next;
104
+ }
105
+ function scrub(value, key3 = "") {
106
+ const attributeKey = key3.toLowerCase();
107
+ const attributeKeys = config?.privacy.redactAttributeKeys ?? [...defaultKeys, "authorization", "cookie"];
108
+ const queryKeys = config?.privacy.redactUrlQueryKeys ?? defaultKeys;
109
+ if (attributeKeys.some((item) => item.toLowerCase() === attributeKey)) return "[redacted]";
110
+ let sanitized = value;
111
+ try {
112
+ const url = new URL(value, typeof location === "undefined" ? "https://kmind.invalid" : location.href);
113
+ queryKeys.forEach((queryKey) => {
114
+ if (url.searchParams.has(queryKey)) url.searchParams.set(queryKey, "[redacted]");
115
+ });
116
+ if (url.origin !== "https://kmind.invalid") sanitized = url.toString();
117
+ else if (value.startsWith("/")) sanitized = `${url.pathname}${url.search}${url.hash}`;
118
+ } catch {
119
+ }
120
+ sanitized = sanitized.replace(/(token|email|cpf|password)=([^&#\s]*)/gi, "$1=[redacted]");
121
+ try {
122
+ return config?.scrub(sanitized, key3) ?? sanitized;
123
+ } catch {
124
+ return sanitized;
125
+ }
126
+ }
127
+ function scrubAttributes(values) {
128
+ return Object.fromEntries(Object.entries(values).map(([key3, value]) => [key3, scrub(String(value), key3)]));
129
+ }
130
+
131
+ // src/transport.ts
132
+ var MAX_QUEUED_EVENTS_PER_SIGNAL = 100;
133
+ var MAX_BATCH_EVENTS = 20;
134
+ var MAX_PAYLOAD_BYTES = 60 * 1024;
135
+ var FLUSH_INTERVAL_MS = 5e3;
136
+ var REQUEST_TIMEOUT_MS = 3e3;
137
+ var queues = { traces: [], metrics: [], logs: [] };
138
+ var nativeFetch = typeof window !== "undefined" ? window.fetch.bind(window) : void 0;
139
+ var timer;
140
+ var config2;
141
+ var active = true;
142
+ var inFlight = { traces: false, metrics: false, logs: false };
143
+ function configureTransport(next) {
144
+ config2 = next;
145
+ active = next.enabled;
146
+ }
147
+ function disableTransport() {
148
+ active = false;
149
+ Object.keys(queues).forEach((key3) => {
150
+ queues[key3] = [];
151
+ });
152
+ }
153
+ function enqueue(signal, event) {
154
+ if (!active || !config2) return;
155
+ if (queues[signal].length >= MAX_QUEUED_EVENTS_PER_SIGNAL) return;
156
+ queues[signal].push(event);
157
+ if (queues[signal].length >= MAX_BATCH_EVENTS) scheduleFlush(0);
158
+ else if (!timer) scheduleFlush(FLUSH_INTERVAL_MS);
159
+ }
160
+ function scheduleFlush(delay) {
161
+ if (timer || typeof window === "undefined") return;
162
+ timer = window.setTimeout(() => {
163
+ timer = void 0;
164
+ void flushAll();
165
+ }, delay);
166
+ }
167
+ function payloadFor(signal, events) {
168
+ const collection = signal === "traces" ? "resourceSpans" : signal === "metrics" ? "resourceMetrics" : "resourceLogs";
169
+ try {
170
+ const payload = JSON.stringify({ [collection]: events.flatMap((event) => event[collection] ?? []), _kmind: { clientKey: config2?.clientKey } });
171
+ return new TextEncoder().encode(payload).byteLength <= MAX_PAYLOAD_BYTES ? payload : void 0;
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ }
176
+ async function flush(signal) {
177
+ if (inFlight[signal] || !config2 || !active) return;
178
+ const events = queues[signal].splice(0, MAX_BATCH_EVENTS);
179
+ if (!events.length) return;
180
+ const payload = payloadFor(signal, events);
181
+ if (!payload) {
182
+ if (queues[signal].length) scheduleFlush(FLUSH_INTERVAL_MS);
183
+ return;
184
+ }
185
+ const url = `${config2.endpoint}/${signal}`;
186
+ inFlight[signal] = true;
187
+ try {
188
+ const beaconSender = typeof navigator === "undefined" ? void 0 : navigator.sendBeacon.bind(navigator);
189
+ const body = new Blob([payload], { type: "application/json" });
190
+ if (body.size < 64 * 1024 && beaconSender?.(url, body)) return;
191
+ if (!nativeFetch) return;
192
+ const controller = new AbortController();
193
+ const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
194
+ try {
195
+ await nativeFetch(url, { method: "POST", keepalive: true, headers: { "Content-Type": "application/json", "X-Kmind-Client-Key": config2.clientKey }, body: payload, signal: controller.signal });
196
+ } finally {
197
+ window.clearTimeout(timeout);
198
+ }
199
+ } catch {
200
+ } finally {
201
+ inFlight[signal] = false;
202
+ if (queues[signal].length) scheduleFlush(FLUSH_INTERVAL_MS);
203
+ }
204
+ }
205
+ async function flushAll() {
206
+ await Promise.all(Object.keys(queues).map(flush));
207
+ }
208
+ function installPageFlush() {
209
+ window.addEventListener("pagehide", () => void flushAll(), { once: false });
210
+ }
211
+
212
+ // src/sampling.ts
213
+ function shouldSample(rate) {
214
+ return rate >= 1 || rate > 0 && Math.random() < rate;
215
+ }
216
+ function shouldPromoteTrace(error, durationMs, slowThresholdMs) {
217
+ return error || durationMs >= slowThresholdMs;
218
+ }
219
+
220
+ // src/tracing.ts
221
+ var config3;
222
+ var current;
223
+ var traceStates = /* @__PURE__ */ new Map();
224
+ function configureTracing(next) {
225
+ config3 = next;
226
+ }
227
+ function getActiveTraceId() {
228
+ return current?.traceId;
229
+ }
230
+ function getActiveSpanContext() {
231
+ return current ? { traceId: current.traceId, spanId: current.spanId } : void 0;
232
+ }
233
+ function startSpan(name, attrs = {}) {
234
+ const parent = current;
235
+ const traceId = parent?.traceId ?? randomHex(16);
236
+ if (!traceStates.has(traceId)) traceStates.set(traceId, { sampled: shouldSample(config3.sampleRate), pending: [] });
237
+ return { traceId, spanId: randomHex(8), parentSpanId: parent?.spanId, name, start: nanoNow(), attrs: scrubAttributes(attrs) };
238
+ }
239
+ function endSpan(span, error = false) {
240
+ span.end = nanoNow();
241
+ span.status = error ? "ERROR" : "OK";
242
+ const state = traceStates.get(span.traceId) ?? { sampled: true, pending: [] };
243
+ const durationMs = Number(BigInt(span.end) - BigInt(span.start)) / 1e6;
244
+ const promoted = shouldPromoteTrace(error, durationMs, config3.slowTraceThresholdMs);
245
+ if (state.sampled || promoted) {
246
+ state.pending.forEach((item) => enqueue("traces", tracePayload(config3.serviceName, item)));
247
+ state.pending = [];
248
+ state.sampled = true;
249
+ enqueue("traces", tracePayload(config3.serviceName, span));
250
+ } else state.pending.push(span);
251
+ if (!span.parentSpanId) traceStates.delete(span.traceId);
252
+ }
253
+ function withActive(span, fn) {
254
+ const previous = current;
255
+ current = span;
256
+ try {
257
+ return fn();
258
+ } finally {
259
+ current = previous;
260
+ }
261
+ }
262
+ function installPageTracing() {
263
+ safeRun(() => {
264
+ const page = startSpan("documentLoad", { "url.full": location.href });
265
+ current = page;
266
+ window.addEventListener("pagehide", () => endSpan(page));
267
+ document.addEventListener("click", () => {
268
+ const span = startSpan("user.click");
269
+ endSpan(span);
270
+ }, true);
271
+ document.addEventListener("submit", () => {
272
+ const span = startSpan("form.submit");
273
+ endSpan(span);
274
+ }, true);
275
+ }, config3.debugTrace);
276
+ }
277
+ function recordError(message2, attrs = {}) {
278
+ safeRun(() => {
279
+ const span = startSpan("exception", { "exception.message": message2, ...attrs });
280
+ endSpan(span, true);
281
+ }, config3.debugTrace);
282
+ }
283
+
284
+ // src/logger.ts
285
+ var config4;
286
+ function configureLogger(next) {
287
+ config4 = next;
288
+ }
289
+ function normalizeAttributes(input) {
290
+ return Object.fromEntries(Object.entries(input).map(([key3, value]) => [key3, scrub(typeof value === "string" ? value : JSON.stringify(value), key3)]));
291
+ }
292
+ function write(severity, message2, attributes2 = {}) {
293
+ safeRun(() => {
294
+ if (!config4) return;
295
+ const context = getActiveSpanContext();
296
+ enqueue("logs", logPayload(config4.serviceName, severity, scrub(message2), {
297
+ ...normalizeAttributes(attributes2),
298
+ trace_id: context?.traceId,
299
+ span_id: context?.spanId
300
+ }));
301
+ }, config4?.debugTrace);
302
+ }
303
+ var logger = {
304
+ info: (message2, attributes2) => write("INFO", message2, attributes2),
305
+ warn: (message2, attributes2) => write("WARN", message2, attributes2),
306
+ error: (message2, attributes2) => write("ERROR", message2, attributes2)
307
+ };
308
+
309
+ // src/errors.ts
310
+ function installErrorCapture(config6) {
311
+ window.addEventListener("error", (event) => safeRun(() => {
312
+ const message2 = scrub(event.message || "Unhandled error");
313
+ const stack = scrub(event.error?.stack ?? "");
314
+ recordError(message2, { "exception.stacktrace": stack });
315
+ logger.error(message2, { "exception.stacktrace": stack, "log.source": "window.onerror" });
316
+ }, config6.debugTrace));
317
+ window.addEventListener("unhandledrejection", (event) => safeRun(() => {
318
+ const message2 = scrub(String(event.reason?.message ?? event.reason ?? "Unhandled rejection"));
319
+ recordError(message2);
320
+ logger.error(message2, { "log.source": "unhandledrejection" });
321
+ }, config6.debugTrace));
322
+ }
323
+
324
+ // src/heartbeat.ts
325
+ var key2 = "kmind.apm.status";
326
+ var interval = 36e5;
327
+ var nativeFetch2 = typeof window !== "undefined" ? window.fetch.bind(window) : void 0;
328
+ async function heartbeat(config6) {
329
+ try {
330
+ if (!nativeFetch2) return;
331
+ const cached = JSON.parse(sessionStorage.getItem(key2) || "null");
332
+ if (cached && Date.now() - cached.at < interval) {
333
+ if (!cached.active) disableTransport();
334
+ return;
335
+ }
336
+ const controller = new AbortController();
337
+ const timeout = window.setTimeout(() => controller.abort(), 3e3);
338
+ try {
339
+ const response = await nativeFetch2(config6.statusEndpoint, { headers: { "X-Kmind-Client-Key": config6.clientKey }, signal: controller.signal });
340
+ const data = await response.json();
341
+ const active2 = data.active !== false;
342
+ sessionStorage.setItem(key2, JSON.stringify({ at: Date.now(), active: active2 }));
343
+ if (!active2) disableTransport();
344
+ } finally {
345
+ window.clearTimeout(timeout);
346
+ }
347
+ } catch {
348
+ }
349
+ }
350
+
351
+ // src/network.ts
352
+ function shouldPropagate(url, config6) {
353
+ try {
354
+ return config6.propagateTraceTo.includes(new URL(url, location.href).hostname);
355
+ } catch {
356
+ return false;
357
+ }
358
+ }
359
+ function traceparent(span) {
360
+ return `00-${span.traceId}-${span.spanId}-01`;
361
+ }
362
+ function installFetchInstrumentation(config6) {
363
+ const original = window.fetch.bind(window);
364
+ window.fetch = (input, init2 = {}) => {
365
+ let span;
366
+ let requestInit = init2;
367
+ try {
368
+ const isRequest = typeof Request !== "undefined" && input instanceof Request;
369
+ const url = typeof input === "string" ? input : isRequest ? input.url : String(input);
370
+ const method = init2.method ?? (isRequest ? input.method : "GET");
371
+ span = startSpan("HTTP " + method, { "http.request.method": method, "url.full": url, "server.address": new URL(url, location.href).hostname });
372
+ if (shouldPropagate(url, config6)) {
373
+ const headers = new Headers(init2.headers ?? (isRequest ? input.headers : void 0));
374
+ headers.set("traceparent", traceparent(span));
375
+ requestInit = { ...init2, headers };
376
+ }
377
+ } catch {
378
+ return original(input, init2);
379
+ }
380
+ let request;
381
+ try {
382
+ request = withActive(span, () => original(input, requestInit));
383
+ } catch (error) {
384
+ safeRun(() => endSpan(span, true), config6.debugTrace);
385
+ throw error;
386
+ }
387
+ return request.then(
388
+ (response) => {
389
+ safeRun(() => endSpan(span, response.status >= 400), config6.debugTrace);
390
+ return response;
391
+ },
392
+ (error) => {
393
+ safeRun(() => endSpan(span, true), config6.debugTrace);
394
+ throw error;
395
+ }
396
+ );
397
+ };
398
+ }
399
+ function installXhrInstrumentation(config6) {
400
+ const open = XMLHttpRequest.prototype.open;
401
+ const send = XMLHttpRequest.prototype.send;
402
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
403
+ safeRun(() => {
404
+ this.__kmind = { method, url: String(url) };
405
+ }, config6.debugTrace);
406
+ return open.apply(this, [method, url, ...rest]);
407
+ };
408
+ XMLHttpRequest.prototype.send = function(...args) {
409
+ const meta = this.__kmind;
410
+ if (!meta) return send.apply(this, args);
411
+ let span;
412
+ safeRun(() => {
413
+ span = startSpan("HTTP " + meta.method, { "http.request.method": meta.method, "url.full": meta.url });
414
+ if (shouldPropagate(meta.url, config6)) this.setRequestHeader("traceparent", traceparent(span));
415
+ this.addEventListener("loadend", () => safeRun(() => endSpan(span, this.status >= 400), config6.debugTrace), { once: true });
416
+ }, config6.debugTrace);
417
+ return send.apply(this, args);
418
+ };
419
+ }
420
+
421
+ // src/vitals.ts
422
+ var import_web_vitals = require("web-vitals");
423
+ function installWebVitals(config6) {
424
+ const report = (metric) => enqueue("metrics", metricPayload(config6.serviceName, `web.vital.${metric.name.toLowerCase()}`, metric.value, {}));
425
+ safeRun(() => {
426
+ (0, import_web_vitals.onCLS)(report);
427
+ (0, import_web_vitals.onFCP)(report);
428
+ (0, import_web_vitals.onINP)(report);
429
+ (0, import_web_vitals.onLCP)(report);
430
+ (0, import_web_vitals.onTTFB)(report);
431
+ }, config6.debugTrace);
432
+ }
433
+
434
+ // src/consoleCapture.ts
435
+ function message(args) {
436
+ return args.map((value) => {
437
+ if (value instanceof Error) return value.stack || value.message;
438
+ if (typeof value === "string") return value;
439
+ try {
440
+ return JSON.stringify(value);
441
+ } catch {
442
+ return String(value);
443
+ }
444
+ }).join(" ");
445
+ }
446
+ function installConsoleCapture(config6) {
447
+ ["warn", "error"].forEach((level) => {
448
+ const original = console[level].bind(console);
449
+ console[level] = (...args) => {
450
+ original(...args);
451
+ safeRun(() => logger[level](message(args), { "log.source": "console" }), config6.debugTrace);
452
+ };
453
+ });
454
+ }
455
+
456
+ // src/index.ts
457
+ var initialized = false;
458
+ var config5;
459
+ function init(input) {
460
+ safeRun(() => {
461
+ if (initialized || typeof window === "undefined") return;
462
+ config5 = resolveConfig(input);
463
+ if (!config5.enabled) return;
464
+ initialized = true;
465
+ configurePrivacy(config5);
466
+ configureTransport(config5);
467
+ configureTracing(config5);
468
+ configureLogger(config5);
469
+ installPageFlush();
470
+ installPageTracing();
471
+ installFetchInstrumentation(config5);
472
+ installXhrInstrumentation(config5);
473
+ installWebVitals(config5);
474
+ installErrorCapture(config5);
475
+ if (config5.captureConsole) installConsoleCapture(config5);
476
+ void heartbeat(config5);
477
+ }, input.debugTrace);
478
+ }
479
+ function recordMetric(name, value, attributes2 = {}) {
480
+ safeRun(() => {
481
+ if (config5 && Number.isFinite(value)) enqueue("metrics", metricPayload(config5.serviceName, name, value, attributes2));
482
+ }, config5?.debugTrace);
483
+ }
484
+ // Annotate the CommonJS export names for ESM import in node:
485
+ 0 && (module.exports = {
486
+ getActiveTraceId,
487
+ init,
488
+ logger,
489
+ recordMetric
490
+ });
@@ -0,0 +1,34 @@
1
+ type PrivacyRules = {
2
+ redactAttributeKeys?: string[];
3
+ redactUrlQueryKeys?: string[];
4
+ };
5
+ type KmindConfig = {
6
+ serviceName: string;
7
+ clientKey: string;
8
+ enabled?: boolean;
9
+ sampleRate?: number;
10
+ debugHttp?: boolean;
11
+ debugTrace?: boolean;
12
+ /** Capture console.warn/error. Disabled by default because it patches global console methods. */
13
+ captureConsole?: boolean;
14
+ propagateTraceTo?: string[];
15
+ endpoint?: string;
16
+ statusEndpoint?: string;
17
+ scrub?: (value: string, key: string) => string;
18
+ privacy?: PrivacyRules;
19
+ slowTraceThresholdMs?: number;
20
+ };
21
+
22
+ declare function getActiveTraceId(): string | undefined;
23
+
24
+ type KmindLogger = {
25
+ info(message: string, attributes?: Record<string, unknown>): void;
26
+ warn(message: string, attributes?: Record<string, unknown>): void;
27
+ error(message: string, attributes?: Record<string, unknown>): void;
28
+ };
29
+ declare const logger: KmindLogger;
30
+
31
+ declare function init(input: KmindConfig): void;
32
+ declare function recordMetric(name: string, value: number, attributes?: Record<string, string>): void;
33
+
34
+ export { type KmindConfig, type KmindLogger, getActiveTraceId, init, logger, recordMetric };
@@ -0,0 +1,34 @@
1
+ type PrivacyRules = {
2
+ redactAttributeKeys?: string[];
3
+ redactUrlQueryKeys?: string[];
4
+ };
5
+ type KmindConfig = {
6
+ serviceName: string;
7
+ clientKey: string;
8
+ enabled?: boolean;
9
+ sampleRate?: number;
10
+ debugHttp?: boolean;
11
+ debugTrace?: boolean;
12
+ /** Capture console.warn/error. Disabled by default because it patches global console methods. */
13
+ captureConsole?: boolean;
14
+ propagateTraceTo?: string[];
15
+ endpoint?: string;
16
+ statusEndpoint?: string;
17
+ scrub?: (value: string, key: string) => string;
18
+ privacy?: PrivacyRules;
19
+ slowTraceThresholdMs?: number;
20
+ };
21
+
22
+ declare function getActiveTraceId(): string | undefined;
23
+
24
+ type KmindLogger = {
25
+ info(message: string, attributes?: Record<string, unknown>): void;
26
+ warn(message: string, attributes?: Record<string, unknown>): void;
27
+ error(message: string, attributes?: Record<string, unknown>): void;
28
+ };
29
+ declare const logger: KmindLogger;
30
+
31
+ declare function init(input: KmindConfig): void;
32
+ declare function recordMetric(name: string, value: number, attributes?: Record<string, string>): void;
33
+
34
+ export { type KmindConfig, type KmindLogger, getActiveTraceId, init, logger, recordMetric };
package/dist/index.js ADDED
@@ -0,0 +1,460 @@
1
+ // src/config.ts
2
+ function resolveConfig(input) {
3
+ if (!input.serviceName.trim()) throw new Error("serviceName is required");
4
+ if (!input.clientKey.trim()) throw new Error("clientKey is required");
5
+ const sampleRate = input.sampleRate ?? 1;
6
+ if (sampleRate < 0 || sampleRate > 1) throw new Error("sampleRate must be between 0 and 1");
7
+ return {
8
+ serviceName: input.serviceName.trim(),
9
+ clientKey: input.clientKey.trim(),
10
+ enabled: input.enabled ?? true,
11
+ sampleRate,
12
+ debugHttp: input.debugHttp ?? false,
13
+ debugTrace: input.debugTrace ?? false,
14
+ captureConsole: input.captureConsole ?? false,
15
+ propagateTraceTo: input.propagateTraceTo ?? [],
16
+ endpoint: (input.endpoint ?? "https://ingest.kmind.com.br/v1").replace(/\/$/, ""),
17
+ statusEndpoint: input.statusEndpoint ?? "https://ingest.kmind.com.br/v1/apm/status",
18
+ scrub: input.scrub ?? ((value) => value),
19
+ privacy: { redactAttributeKeys: input.privacy?.redactAttributeKeys ?? ["token", "email", "cpf", "password", "authorization", "cookie"], redactUrlQueryKeys: input.privacy?.redactUrlQueryKeys ?? ["token", "email", "cpf", "password"] },
20
+ slowTraceThresholdMs: input.slowTraceThresholdMs ?? 1e3
21
+ };
22
+ }
23
+
24
+ // src/utils.ts
25
+ function safeRun(fn, debug = false) {
26
+ try {
27
+ fn();
28
+ } catch (error) {
29
+ if (debug) console.debug("[Kmind APM]", error);
30
+ }
31
+ }
32
+ function nanoNow() {
33
+ return String(BigInt(Date.now()) * 1000000n);
34
+ }
35
+ function randomHex(bytes) {
36
+ const data = new Uint8Array(bytes);
37
+ crypto.getRandomValues(data);
38
+ return Array.from(data, (byte) => byte.toString(16).padStart(2, "0")).join("");
39
+ }
40
+ function attributes(values) {
41
+ return Object.entries(values).filter(([, value]) => value !== void 0 && value !== null).map(([key3, value]) => ({ key: key3, value: { stringValue: String(value) } }));
42
+ }
43
+
44
+ // src/session.ts
45
+ var key = "kmind.apm.session.id";
46
+ function sessionId() {
47
+ try {
48
+ const value = sessionStorage.getItem(key);
49
+ if (value) return value;
50
+ const next = randomHex(16);
51
+ sessionStorage.setItem(key, next);
52
+ return next;
53
+ } catch {
54
+ return randomHex(16);
55
+ }
56
+ }
57
+
58
+ // src/otlp.ts
59
+ var resource = (serviceName) => ({ attributes: attributes({ "service.name": serviceName, "session.id": sessionId(), "telemetry.sdk.name": "kmind-apm-web" }) });
60
+ function tracePayload(serviceName, span) {
61
+ return { resourceSpans: [{ resource: resource(serviceName), scopeSpans: [{ scope: { name: "kmind-apm-web" }, spans: [{ traceId: span.traceId, spanId: span.spanId, parentSpanId: span.parentSpanId, name: span.name, kind: 3, startTimeUnixNano: span.start, endTimeUnixNano: span.end ?? nanoNow(), attributes: attributes(span.attrs), status: span.status === "ERROR" ? { code: 2 } : { code: 1 } }] }] }] };
62
+ }
63
+ function metricPayload(serviceName, name, value, attrs) {
64
+ return { resourceMetrics: [{ resource: resource(serviceName), scopeMetrics: [{ scope: { name: "kmind-apm-web" }, metrics: [{ name, unit: "ms", gauge: { dataPoints: [{ asDouble: value, timeUnixNano: nanoNow(), attributes: attributes({ "url.path": location.pathname, "session.id": sessionId(), ...attrs }) }] } }] }] }] };
65
+ }
66
+ function logPayload(serviceName, severity, message2, attrs) {
67
+ return { resourceLogs: [{ resource: resource(serviceName), scopeLogs: [{ scope: { name: "kmind-apm-web" }, logRecords: [{ timeUnixNano: nanoNow(), severityText: severity, body: { stringValue: message2 }, attributes: attributes({ "session.id": sessionId(), ...attrs }) }] }] }] };
68
+ }
69
+
70
+ // src/privacy.ts
71
+ var config;
72
+ var defaultKeys = ["token", "email", "cpf", "password"];
73
+ function configurePrivacy(next) {
74
+ config = next;
75
+ }
76
+ function scrub(value, key3 = "") {
77
+ const attributeKey = key3.toLowerCase();
78
+ const attributeKeys = config?.privacy.redactAttributeKeys ?? [...defaultKeys, "authorization", "cookie"];
79
+ const queryKeys = config?.privacy.redactUrlQueryKeys ?? defaultKeys;
80
+ if (attributeKeys.some((item) => item.toLowerCase() === attributeKey)) return "[redacted]";
81
+ let sanitized = value;
82
+ try {
83
+ const url = new URL(value, typeof location === "undefined" ? "https://kmind.invalid" : location.href);
84
+ queryKeys.forEach((queryKey) => {
85
+ if (url.searchParams.has(queryKey)) url.searchParams.set(queryKey, "[redacted]");
86
+ });
87
+ if (url.origin !== "https://kmind.invalid") sanitized = url.toString();
88
+ else if (value.startsWith("/")) sanitized = `${url.pathname}${url.search}${url.hash}`;
89
+ } catch {
90
+ }
91
+ sanitized = sanitized.replace(/(token|email|cpf|password)=([^&#\s]*)/gi, "$1=[redacted]");
92
+ try {
93
+ return config?.scrub(sanitized, key3) ?? sanitized;
94
+ } catch {
95
+ return sanitized;
96
+ }
97
+ }
98
+ function scrubAttributes(values) {
99
+ return Object.fromEntries(Object.entries(values).map(([key3, value]) => [key3, scrub(String(value), key3)]));
100
+ }
101
+
102
+ // src/transport.ts
103
+ var MAX_QUEUED_EVENTS_PER_SIGNAL = 100;
104
+ var MAX_BATCH_EVENTS = 20;
105
+ var MAX_PAYLOAD_BYTES = 60 * 1024;
106
+ var FLUSH_INTERVAL_MS = 5e3;
107
+ var REQUEST_TIMEOUT_MS = 3e3;
108
+ var queues = { traces: [], metrics: [], logs: [] };
109
+ var nativeFetch = typeof window !== "undefined" ? window.fetch.bind(window) : void 0;
110
+ var timer;
111
+ var config2;
112
+ var active = true;
113
+ var inFlight = { traces: false, metrics: false, logs: false };
114
+ function configureTransport(next) {
115
+ config2 = next;
116
+ active = next.enabled;
117
+ }
118
+ function disableTransport() {
119
+ active = false;
120
+ Object.keys(queues).forEach((key3) => {
121
+ queues[key3] = [];
122
+ });
123
+ }
124
+ function enqueue(signal, event) {
125
+ if (!active || !config2) return;
126
+ if (queues[signal].length >= MAX_QUEUED_EVENTS_PER_SIGNAL) return;
127
+ queues[signal].push(event);
128
+ if (queues[signal].length >= MAX_BATCH_EVENTS) scheduleFlush(0);
129
+ else if (!timer) scheduleFlush(FLUSH_INTERVAL_MS);
130
+ }
131
+ function scheduleFlush(delay) {
132
+ if (timer || typeof window === "undefined") return;
133
+ timer = window.setTimeout(() => {
134
+ timer = void 0;
135
+ void flushAll();
136
+ }, delay);
137
+ }
138
+ function payloadFor(signal, events) {
139
+ const collection = signal === "traces" ? "resourceSpans" : signal === "metrics" ? "resourceMetrics" : "resourceLogs";
140
+ try {
141
+ const payload = JSON.stringify({ [collection]: events.flatMap((event) => event[collection] ?? []), _kmind: { clientKey: config2?.clientKey } });
142
+ return new TextEncoder().encode(payload).byteLength <= MAX_PAYLOAD_BYTES ? payload : void 0;
143
+ } catch {
144
+ return void 0;
145
+ }
146
+ }
147
+ async function flush(signal) {
148
+ if (inFlight[signal] || !config2 || !active) return;
149
+ const events = queues[signal].splice(0, MAX_BATCH_EVENTS);
150
+ if (!events.length) return;
151
+ const payload = payloadFor(signal, events);
152
+ if (!payload) {
153
+ if (queues[signal].length) scheduleFlush(FLUSH_INTERVAL_MS);
154
+ return;
155
+ }
156
+ const url = `${config2.endpoint}/${signal}`;
157
+ inFlight[signal] = true;
158
+ try {
159
+ const beaconSender = typeof navigator === "undefined" ? void 0 : navigator.sendBeacon.bind(navigator);
160
+ const body = new Blob([payload], { type: "application/json" });
161
+ if (body.size < 64 * 1024 && beaconSender?.(url, body)) return;
162
+ if (!nativeFetch) return;
163
+ const controller = new AbortController();
164
+ const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
165
+ try {
166
+ await nativeFetch(url, { method: "POST", keepalive: true, headers: { "Content-Type": "application/json", "X-Kmind-Client-Key": config2.clientKey }, body: payload, signal: controller.signal });
167
+ } finally {
168
+ window.clearTimeout(timeout);
169
+ }
170
+ } catch {
171
+ } finally {
172
+ inFlight[signal] = false;
173
+ if (queues[signal].length) scheduleFlush(FLUSH_INTERVAL_MS);
174
+ }
175
+ }
176
+ async function flushAll() {
177
+ await Promise.all(Object.keys(queues).map(flush));
178
+ }
179
+ function installPageFlush() {
180
+ window.addEventListener("pagehide", () => void flushAll(), { once: false });
181
+ }
182
+
183
+ // src/sampling.ts
184
+ function shouldSample(rate) {
185
+ return rate >= 1 || rate > 0 && Math.random() < rate;
186
+ }
187
+ function shouldPromoteTrace(error, durationMs, slowThresholdMs) {
188
+ return error || durationMs >= slowThresholdMs;
189
+ }
190
+
191
+ // src/tracing.ts
192
+ var config3;
193
+ var current;
194
+ var traceStates = /* @__PURE__ */ new Map();
195
+ function configureTracing(next) {
196
+ config3 = next;
197
+ }
198
+ function getActiveTraceId() {
199
+ return current?.traceId;
200
+ }
201
+ function getActiveSpanContext() {
202
+ return current ? { traceId: current.traceId, spanId: current.spanId } : void 0;
203
+ }
204
+ function startSpan(name, attrs = {}) {
205
+ const parent = current;
206
+ const traceId = parent?.traceId ?? randomHex(16);
207
+ if (!traceStates.has(traceId)) traceStates.set(traceId, { sampled: shouldSample(config3.sampleRate), pending: [] });
208
+ return { traceId, spanId: randomHex(8), parentSpanId: parent?.spanId, name, start: nanoNow(), attrs: scrubAttributes(attrs) };
209
+ }
210
+ function endSpan(span, error = false) {
211
+ span.end = nanoNow();
212
+ span.status = error ? "ERROR" : "OK";
213
+ const state = traceStates.get(span.traceId) ?? { sampled: true, pending: [] };
214
+ const durationMs = Number(BigInt(span.end) - BigInt(span.start)) / 1e6;
215
+ const promoted = shouldPromoteTrace(error, durationMs, config3.slowTraceThresholdMs);
216
+ if (state.sampled || promoted) {
217
+ state.pending.forEach((item) => enqueue("traces", tracePayload(config3.serviceName, item)));
218
+ state.pending = [];
219
+ state.sampled = true;
220
+ enqueue("traces", tracePayload(config3.serviceName, span));
221
+ } else state.pending.push(span);
222
+ if (!span.parentSpanId) traceStates.delete(span.traceId);
223
+ }
224
+ function withActive(span, fn) {
225
+ const previous = current;
226
+ current = span;
227
+ try {
228
+ return fn();
229
+ } finally {
230
+ current = previous;
231
+ }
232
+ }
233
+ function installPageTracing() {
234
+ safeRun(() => {
235
+ const page = startSpan("documentLoad", { "url.full": location.href });
236
+ current = page;
237
+ window.addEventListener("pagehide", () => endSpan(page));
238
+ document.addEventListener("click", () => {
239
+ const span = startSpan("user.click");
240
+ endSpan(span);
241
+ }, true);
242
+ document.addEventListener("submit", () => {
243
+ const span = startSpan("form.submit");
244
+ endSpan(span);
245
+ }, true);
246
+ }, config3.debugTrace);
247
+ }
248
+ function recordError(message2, attrs = {}) {
249
+ safeRun(() => {
250
+ const span = startSpan("exception", { "exception.message": message2, ...attrs });
251
+ endSpan(span, true);
252
+ }, config3.debugTrace);
253
+ }
254
+
255
+ // src/logger.ts
256
+ var config4;
257
+ function configureLogger(next) {
258
+ config4 = next;
259
+ }
260
+ function normalizeAttributes(input) {
261
+ return Object.fromEntries(Object.entries(input).map(([key3, value]) => [key3, scrub(typeof value === "string" ? value : JSON.stringify(value), key3)]));
262
+ }
263
+ function write(severity, message2, attributes2 = {}) {
264
+ safeRun(() => {
265
+ if (!config4) return;
266
+ const context = getActiveSpanContext();
267
+ enqueue("logs", logPayload(config4.serviceName, severity, scrub(message2), {
268
+ ...normalizeAttributes(attributes2),
269
+ trace_id: context?.traceId,
270
+ span_id: context?.spanId
271
+ }));
272
+ }, config4?.debugTrace);
273
+ }
274
+ var logger = {
275
+ info: (message2, attributes2) => write("INFO", message2, attributes2),
276
+ warn: (message2, attributes2) => write("WARN", message2, attributes2),
277
+ error: (message2, attributes2) => write("ERROR", message2, attributes2)
278
+ };
279
+
280
+ // src/errors.ts
281
+ function installErrorCapture(config6) {
282
+ window.addEventListener("error", (event) => safeRun(() => {
283
+ const message2 = scrub(event.message || "Unhandled error");
284
+ const stack = scrub(event.error?.stack ?? "");
285
+ recordError(message2, { "exception.stacktrace": stack });
286
+ logger.error(message2, { "exception.stacktrace": stack, "log.source": "window.onerror" });
287
+ }, config6.debugTrace));
288
+ window.addEventListener("unhandledrejection", (event) => safeRun(() => {
289
+ const message2 = scrub(String(event.reason?.message ?? event.reason ?? "Unhandled rejection"));
290
+ recordError(message2);
291
+ logger.error(message2, { "log.source": "unhandledrejection" });
292
+ }, config6.debugTrace));
293
+ }
294
+
295
+ // src/heartbeat.ts
296
+ var key2 = "kmind.apm.status";
297
+ var interval = 36e5;
298
+ var nativeFetch2 = typeof window !== "undefined" ? window.fetch.bind(window) : void 0;
299
+ async function heartbeat(config6) {
300
+ try {
301
+ if (!nativeFetch2) return;
302
+ const cached = JSON.parse(sessionStorage.getItem(key2) || "null");
303
+ if (cached && Date.now() - cached.at < interval) {
304
+ if (!cached.active) disableTransport();
305
+ return;
306
+ }
307
+ const controller = new AbortController();
308
+ const timeout = window.setTimeout(() => controller.abort(), 3e3);
309
+ try {
310
+ const response = await nativeFetch2(config6.statusEndpoint, { headers: { "X-Kmind-Client-Key": config6.clientKey }, signal: controller.signal });
311
+ const data = await response.json();
312
+ const active2 = data.active !== false;
313
+ sessionStorage.setItem(key2, JSON.stringify({ at: Date.now(), active: active2 }));
314
+ if (!active2) disableTransport();
315
+ } finally {
316
+ window.clearTimeout(timeout);
317
+ }
318
+ } catch {
319
+ }
320
+ }
321
+
322
+ // src/network.ts
323
+ function shouldPropagate(url, config6) {
324
+ try {
325
+ return config6.propagateTraceTo.includes(new URL(url, location.href).hostname);
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
330
+ function traceparent(span) {
331
+ return `00-${span.traceId}-${span.spanId}-01`;
332
+ }
333
+ function installFetchInstrumentation(config6) {
334
+ const original = window.fetch.bind(window);
335
+ window.fetch = (input, init2 = {}) => {
336
+ let span;
337
+ let requestInit = init2;
338
+ try {
339
+ const isRequest = typeof Request !== "undefined" && input instanceof Request;
340
+ const url = typeof input === "string" ? input : isRequest ? input.url : String(input);
341
+ const method = init2.method ?? (isRequest ? input.method : "GET");
342
+ span = startSpan("HTTP " + method, { "http.request.method": method, "url.full": url, "server.address": new URL(url, location.href).hostname });
343
+ if (shouldPropagate(url, config6)) {
344
+ const headers = new Headers(init2.headers ?? (isRequest ? input.headers : void 0));
345
+ headers.set("traceparent", traceparent(span));
346
+ requestInit = { ...init2, headers };
347
+ }
348
+ } catch {
349
+ return original(input, init2);
350
+ }
351
+ let request;
352
+ try {
353
+ request = withActive(span, () => original(input, requestInit));
354
+ } catch (error) {
355
+ safeRun(() => endSpan(span, true), config6.debugTrace);
356
+ throw error;
357
+ }
358
+ return request.then(
359
+ (response) => {
360
+ safeRun(() => endSpan(span, response.status >= 400), config6.debugTrace);
361
+ return response;
362
+ },
363
+ (error) => {
364
+ safeRun(() => endSpan(span, true), config6.debugTrace);
365
+ throw error;
366
+ }
367
+ );
368
+ };
369
+ }
370
+ function installXhrInstrumentation(config6) {
371
+ const open = XMLHttpRequest.prototype.open;
372
+ const send = XMLHttpRequest.prototype.send;
373
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
374
+ safeRun(() => {
375
+ this.__kmind = { method, url: String(url) };
376
+ }, config6.debugTrace);
377
+ return open.apply(this, [method, url, ...rest]);
378
+ };
379
+ XMLHttpRequest.prototype.send = function(...args) {
380
+ const meta = this.__kmind;
381
+ if (!meta) return send.apply(this, args);
382
+ let span;
383
+ safeRun(() => {
384
+ span = startSpan("HTTP " + meta.method, { "http.request.method": meta.method, "url.full": meta.url });
385
+ if (shouldPropagate(meta.url, config6)) this.setRequestHeader("traceparent", traceparent(span));
386
+ this.addEventListener("loadend", () => safeRun(() => endSpan(span, this.status >= 400), config6.debugTrace), { once: true });
387
+ }, config6.debugTrace);
388
+ return send.apply(this, args);
389
+ };
390
+ }
391
+
392
+ // src/vitals.ts
393
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
394
+ function installWebVitals(config6) {
395
+ const report = (metric) => enqueue("metrics", metricPayload(config6.serviceName, `web.vital.${metric.name.toLowerCase()}`, metric.value, {}));
396
+ safeRun(() => {
397
+ onCLS(report);
398
+ onFCP(report);
399
+ onINP(report);
400
+ onLCP(report);
401
+ onTTFB(report);
402
+ }, config6.debugTrace);
403
+ }
404
+
405
+ // src/consoleCapture.ts
406
+ function message(args) {
407
+ return args.map((value) => {
408
+ if (value instanceof Error) return value.stack || value.message;
409
+ if (typeof value === "string") return value;
410
+ try {
411
+ return JSON.stringify(value);
412
+ } catch {
413
+ return String(value);
414
+ }
415
+ }).join(" ");
416
+ }
417
+ function installConsoleCapture(config6) {
418
+ ["warn", "error"].forEach((level) => {
419
+ const original = console[level].bind(console);
420
+ console[level] = (...args) => {
421
+ original(...args);
422
+ safeRun(() => logger[level](message(args), { "log.source": "console" }), config6.debugTrace);
423
+ };
424
+ });
425
+ }
426
+
427
+ // src/index.ts
428
+ var initialized = false;
429
+ var config5;
430
+ function init(input) {
431
+ safeRun(() => {
432
+ if (initialized || typeof window === "undefined") return;
433
+ config5 = resolveConfig(input);
434
+ if (!config5.enabled) return;
435
+ initialized = true;
436
+ configurePrivacy(config5);
437
+ configureTransport(config5);
438
+ configureTracing(config5);
439
+ configureLogger(config5);
440
+ installPageFlush();
441
+ installPageTracing();
442
+ installFetchInstrumentation(config5);
443
+ installXhrInstrumentation(config5);
444
+ installWebVitals(config5);
445
+ installErrorCapture(config5);
446
+ if (config5.captureConsole) installConsoleCapture(config5);
447
+ void heartbeat(config5);
448
+ }, input.debugTrace);
449
+ }
450
+ function recordMetric(name, value, attributes2 = {}) {
451
+ safeRun(() => {
452
+ if (config5 && Number.isFinite(value)) enqueue("metrics", metricPayload(config5.serviceName, name, value, attributes2));
453
+ }, config5?.debugTrace);
454
+ }
455
+ export {
456
+ getActiveTraceId,
457
+ init,
458
+ logger,
459
+ recordMetric
460
+ };
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "kmind-apm-web",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic browser observability SDK for Kmind.",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } },
10
+ "files": ["dist"],
11
+ "scripts": { "build": "tsup && node scripts/check-size.mjs", "test": "vitest run" },
12
+ "dependencies": { "web-vitals": "^5.1.0" },
13
+ "devDependencies": { "@types/node": "^22.15.3", "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.1.3" }
14
+ }