@pmate/log-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 +54 -2
- package/dist/access.d.ts +11 -0
- package/dist/access.js +32 -0
- package/dist/browser.d.ts +18 -0
- package/dist/browser.js +105 -0
- package/dist/node.d.ts +32 -0
- package/dist/node.js +128 -0
- package/dist/react.d.ts +10 -0
- package/dist/react.js +80 -0
- package/dist/shared.d.ts +24 -0
- package/dist/shared.js +30 -0
- package/package.json +55 -3
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pmate/log-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Background
|
|
4
|
+
PMate logs need to connect anonymous page visits, signed-in Profiles and backend requests without merging different Profiles of one Account. This SDK adds access/profile context to the existing topic log protocol. It reuses `@pmate/log` for browser delivery and supports the same ingest protocol or a trusted Store sink on Node.
|
|
5
|
+
|
|
6
|
+
## Identity
|
|
7
|
+
`accessId` is a random tab visit ID. Reload/history restore reuses sessionStorage; a new document navigation starts a visit (including new/duplicated tabs). An SPA route change retains it. After 30 minutes without interaction, the next interaction or explicit event starts another visit. Storage failures fall back to memory. Background vitals do not extend the visit. This is not a persistent visitor ID.
|
|
8
|
+
|
|
9
|
+
`profileId` is optional and scoped by `appId`. Logging in keeps accessId, logout clears profileId, and queued events retain their original identity. No Account-level identity merging occurs. Browser identity is client-reported; Node identity must come from verified server authentication. Neither accessId nor telemetry is an authorization proof.
|
|
10
|
+
|
|
11
|
+
## React
|
|
12
|
+
```tsx
|
|
13
|
+
import { LogProvider, useLog } from '@pmate/log-sdk/react'
|
|
14
|
+
const options = {
|
|
15
|
+
appId: 'pmate:hello', serviceId: 'pmate:hello', environment: 'prod',
|
|
16
|
+
endpoint: 'https://log-api.pmate.chat', key: 'REGISTERED_PUBLIC_INTAKE_KEY',
|
|
17
|
+
apiOrigins: ['https://api.example.com'],
|
|
18
|
+
}
|
|
19
|
+
function App({ profileId }) {
|
|
20
|
+
return <LogProvider options={options} profileId={profileId}><Content /></LogProvider>
|
|
21
|
+
}
|
|
22
|
+
function Content() {
|
|
23
|
+
const log = useLog()
|
|
24
|
+
return <button onClick={() => log.append('application', {data:{event:'feature.opened'}})}>Open</button>
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
Keep provider options stable. Initialization happens after mount, is SSR-safe, and suppresses React StrictMode's discarded initialization. Automatic PV/vitals/errors retain the existing browser SDK behavior. `beforeSend` can redact application-specific paths or drop an event. Never include credentials or request/response bodies.
|
|
28
|
+
|
|
29
|
+
## Browser
|
|
30
|
+
```ts
|
|
31
|
+
import { createLogger } from '@pmate/log-sdk/browser'
|
|
32
|
+
const log = createLogger(options)
|
|
33
|
+
log.setProfileId(selectedProfileId) // null on logout
|
|
34
|
+
await log.fetch('https://api.example.com/resource')
|
|
35
|
+
await log.close()
|
|
36
|
+
```
|
|
37
|
+
`log.fetch` adds `x-pmate-access-id` and `x-request-id` only to explicitly allowed API origins. It also sends `x-pmate-profile-id` when a profile is selected; this is an untrusted selection claim, not authentication. The server must verify the selected profile belongs to the authenticated account and expected App before passing it to Node context. It refuses redirects on those requests. APIs must allow these headers in CORS. Global fetch is never patched. Only the wrapped requests carry correlation.
|
|
38
|
+
|
|
39
|
+
## Node
|
|
40
|
+
```ts
|
|
41
|
+
import { createLogger } from '@pmate/log-sdk/node'
|
|
42
|
+
const log = createLogger({
|
|
43
|
+
appId: 'pmate:hello', serviceId: 'pmate:hello-api', environment: 'prod',
|
|
44
|
+
endpoint: 'https://log-api.pmate.chat', token: process.env.LOG_INGEST_TOKEN,
|
|
45
|
+
})
|
|
46
|
+
await log.withRequest(request, {profileId: authenticatedProfile?.id}, async scope => {
|
|
47
|
+
log.append('application', {data:{event:'http.request', requestId:scope.requestId}})
|
|
48
|
+
// business work; report completion/error without raw bodies
|
|
49
|
+
})
|
|
50
|
+
await log.close()
|
|
51
|
+
```
|
|
52
|
+
Use authenticatedProfile from server authentication, never a client body/header. AsyncLocalStorage isolates concurrent requests and anonymous requests clear inherited identity. Alternatively provide `sink(batch, signal)` backed by existing `@pmate/store`; the sink must preserve item IDs/time, respect cancellation and report acceptance. The queue is bounded, attempts at most three times with the original event ID, reports failures without payloads, and does not block business work. `close()` stops admission and drains outstanding events; telemetry is best effort.
|
|
53
|
+
|
|
54
|
+
## Counting
|
|
55
|
+
The envelope `id` is the event deduplication key. PV counts pv events; sessions deduplicate `(appId, accessId)`; Profile UV deduplicates `(appId, profileId)` in a defined time window/topic. Missing identifiers are reported separately. Never count HTTP requests or vitals as page views, or infer first-ever visits from a limited retention window.
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const ACCESS_IDLE_MS: number;
|
|
2
|
+
export declare function createAccessSession(options: {
|
|
3
|
+
appId: string;
|
|
4
|
+
storage?: Pick<Storage, "getItem" | "setItem">;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
randomId?: () => string;
|
|
7
|
+
fresh?: boolean;
|
|
8
|
+
}): {
|
|
9
|
+
snapshot: () => string;
|
|
10
|
+
touch(): string;
|
|
11
|
+
};
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { validId } from "./shared.js";
|
|
2
|
+
export const ACCESS_IDLE_MS = 30 * 60 * 1000;
|
|
3
|
+
export function createAccessSession(options) {
|
|
4
|
+
const now = options.now ?? Date.now, randomId = options.randomId ?? (() => crypto.randomUUID());
|
|
5
|
+
const key = `pmate.log.access.v1:${options.appId}`;
|
|
6
|
+
let session;
|
|
7
|
+
if (!options.fresh)
|
|
8
|
+
try {
|
|
9
|
+
const stored = JSON.parse(options.storage?.getItem(key) ?? "null");
|
|
10
|
+
if (validId(stored?.id) && Number.isSafeInteger(stored?.lastActivity) && stored.lastActivity <= now() && now() - stored.lastActivity < ACCESS_IDLE_MS)
|
|
11
|
+
session = stored;
|
|
12
|
+
}
|
|
13
|
+
catch { /* Storage may be disabled or corrupt. */ }
|
|
14
|
+
session ??= { id: randomId(), lastActivity: now() };
|
|
15
|
+
const save = () => { try {
|
|
16
|
+
options.storage?.setItem(key, JSON.stringify(session));
|
|
17
|
+
}
|
|
18
|
+
catch { /* In-memory session remains usable. */ } };
|
|
19
|
+
save();
|
|
20
|
+
return {
|
|
21
|
+
snapshot: () => session.id,
|
|
22
|
+
touch() {
|
|
23
|
+
const time = now();
|
|
24
|
+
if (time - session.lastActivity >= ACCESS_IDLE_MS || time < session.lastActivity)
|
|
25
|
+
session = { id: randomId(), lastActivity: time };
|
|
26
|
+
else
|
|
27
|
+
session.lastActivity = time;
|
|
28
|
+
save();
|
|
29
|
+
return session.id;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type BrowserLogOptions } from "@pmate/log";
|
|
2
|
+
import { type LogContext, type LogInput } from "./shared.js";
|
|
3
|
+
export type BrowserLoggerOptions = BrowserLogOptions & {
|
|
4
|
+
appId: string;
|
|
5
|
+
profileId?: string | null;
|
|
6
|
+
apiOrigins?: string[];
|
|
7
|
+
};
|
|
8
|
+
export type BrowserLogger = ReturnType<typeof createLogger>;
|
|
9
|
+
/** Own one instance per application document. Server imports never touch window. */
|
|
10
|
+
export declare function createLogger(options: BrowserLoggerOptions): {
|
|
11
|
+
getContext: () => LogContext;
|
|
12
|
+
setProfileId(value?: string | null): void;
|
|
13
|
+
append(topic: string, input: LogInput | string): void;
|
|
14
|
+
/** Explicit request wrapper: never patches global fetch or decorates unlisted origins. */
|
|
15
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
16
|
+
flush: () => Promise<void>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createLogger as createTransport } from "@pmate/log";
|
|
2
|
+
import { createAccessSession } from "./access.js";
|
|
3
|
+
import { ACCESS_HEADER, PROFILE_HEADER, REQUEST_HEADER, validId, validateAppId, withContext } from "./shared.js";
|
|
4
|
+
/** Own one instance per application document. Server imports never touch window. */
|
|
5
|
+
export function createLogger(options) {
|
|
6
|
+
if (typeof window === "undefined")
|
|
7
|
+
throw Error("Browser logger requires a browser");
|
|
8
|
+
validateAppId(options.appId);
|
|
9
|
+
if (options.profileId != null && !validId(options.profileId))
|
|
10
|
+
throw Error("Invalid profileId");
|
|
11
|
+
let storage;
|
|
12
|
+
try {
|
|
13
|
+
storage = window.sessionStorage;
|
|
14
|
+
}
|
|
15
|
+
catch { /* Private browsing fallback. */ }
|
|
16
|
+
const navigation = performance.getEntriesByType?.("navigation")[0];
|
|
17
|
+
// New documents reached by navigation (including cloned tabs) start a visit;
|
|
18
|
+
// reload and history restoration may reuse the tab's stored visit.
|
|
19
|
+
const access = createAccessSession({ appId: options.appId, storage, fresh: navigation?.type === "navigate" });
|
|
20
|
+
let profileId = options.profileId ?? undefined, closed = false;
|
|
21
|
+
const context = () => ({ appId: options.appId, accessId: access.snapshot(), ...(profileId ? { profileId } : {}), identitySource: "browser" });
|
|
22
|
+
const views = new Map();
|
|
23
|
+
const origins = new Set((options.apiOrigins ?? []).map(value => {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (url.origin !== value || url.username || url.password)
|
|
26
|
+
throw Error("apiOrigins must contain exact origins");
|
|
27
|
+
return value;
|
|
28
|
+
}));
|
|
29
|
+
const touch = () => { if (!closed)
|
|
30
|
+
access.touch(); };
|
|
31
|
+
const events = ["pointerdown", "keydown", "scroll", "touchstart"];
|
|
32
|
+
for (const event of events)
|
|
33
|
+
window.addEventListener(event, touch, { passive: true });
|
|
34
|
+
let transport;
|
|
35
|
+
try {
|
|
36
|
+
transport = createTransport({ ...options, beforeSend(topic, input) {
|
|
37
|
+
if (topic === "pv")
|
|
38
|
+
access.touch();
|
|
39
|
+
const viewId = input.data?.pageViewId;
|
|
40
|
+
let captured = context();
|
|
41
|
+
if (topic === "pv" && typeof viewId === "string") {
|
|
42
|
+
views.set(viewId, captured);
|
|
43
|
+
if (views.size > 256)
|
|
44
|
+
views.delete(views.keys().next().value);
|
|
45
|
+
}
|
|
46
|
+
else if (topic === "vitals" && typeof viewId === "string") {
|
|
47
|
+
// Delayed document metrics retain the identity of their original visit.
|
|
48
|
+
const original = views.get(viewId);
|
|
49
|
+
if (original)
|
|
50
|
+
captured = original;
|
|
51
|
+
else
|
|
52
|
+
captured = { appId: options.appId, identitySource: "browser" };
|
|
53
|
+
}
|
|
54
|
+
const decorated = withContext(input, captured);
|
|
55
|
+
return options.beforeSend ? options.beforeSend(topic, decorated) : decorated;
|
|
56
|
+
} });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
for (const event of events)
|
|
60
|
+
window.removeEventListener(event, touch);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
getContext: context,
|
|
65
|
+
setProfileId(value) {
|
|
66
|
+
if (closed)
|
|
67
|
+
return;
|
|
68
|
+
if (value != null && !validId(value))
|
|
69
|
+
throw Error("Invalid profileId");
|
|
70
|
+
const next = value ?? undefined;
|
|
71
|
+
if (next === profileId)
|
|
72
|
+
return;
|
|
73
|
+
profileId = next;
|
|
74
|
+
access.touch();
|
|
75
|
+
transport.append("application", { data: { event: next ? "identity.identified" : "identity.cleared" } });
|
|
76
|
+
},
|
|
77
|
+
append(topic, input) { if (!closed) {
|
|
78
|
+
access.touch();
|
|
79
|
+
transport.append(topic, input);
|
|
80
|
+
} },
|
|
81
|
+
/** Explicit request wrapper: never patches global fetch or decorates unlisted origins. */
|
|
82
|
+
fetch(input, init) {
|
|
83
|
+
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
84
|
+
if (!closed && origins.has(url.origin)) {
|
|
85
|
+
access.touch();
|
|
86
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
87
|
+
headers.set(ACCESS_HEADER, access.snapshot());
|
|
88
|
+
// Selection only: servers must verify ownership before using this identity.
|
|
89
|
+
if (profileId)
|
|
90
|
+
headers.set(PROFILE_HEADER, profileId);
|
|
91
|
+
else
|
|
92
|
+
headers.delete(PROFILE_HEADER);
|
|
93
|
+
if (!validId(headers.get(REQUEST_HEADER)))
|
|
94
|
+
headers.set(REQUEST_HEADER, crypto.randomUUID());
|
|
95
|
+
// Refuse redirects so correlation headers cannot leak to another origin.
|
|
96
|
+
return fetch(input, { ...init, headers, redirect: "error" });
|
|
97
|
+
}
|
|
98
|
+
return fetch(input, init);
|
|
99
|
+
},
|
|
100
|
+
flush: transport.flush,
|
|
101
|
+
async close() { if (closed)
|
|
102
|
+
return; closed = true; for (const event of events)
|
|
103
|
+
window.removeEventListener(event, touch); await transport.close(); },
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type LogContext, type LogEntry, type LogInput } from "./shared.js";
|
|
2
|
+
export type LogBatch = {
|
|
3
|
+
serviceId: string;
|
|
4
|
+
environment: string;
|
|
5
|
+
topic: string;
|
|
6
|
+
items: LogEntry[];
|
|
7
|
+
};
|
|
8
|
+
export type NodeLoggerOptions = {
|
|
9
|
+
appId: string;
|
|
10
|
+
serviceId: string;
|
|
11
|
+
environment: string;
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
token?: string;
|
|
14
|
+
/** A trusted sink may use @pmate/store appendMany; retain each entry's id/time. */
|
|
15
|
+
sink?: (batch: LogBatch, signal: AbortSignal) => Promise<void>;
|
|
16
|
+
maxQueue?: number;
|
|
17
|
+
flushIntervalMs?: number;
|
|
18
|
+
onWarning?: (event: {
|
|
19
|
+
code: "log_dropped" | "log_send_failed";
|
|
20
|
+
count: number;
|
|
21
|
+
}) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare function createLogger(options: NodeLoggerOptions): {
|
|
24
|
+
getContext: () => LogContext;
|
|
25
|
+
/** verifiedProfile comes from server authentication, never request headers/body. */
|
|
26
|
+
withRequest<T>(request: Request, verifiedProfile: {
|
|
27
|
+
profileId?: string | null;
|
|
28
|
+
}, run: (scope: LogContext) => T): T;
|
|
29
|
+
append(topic: string, input: LogInput | string): void;
|
|
30
|
+
flush: () => Promise<void>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
};
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { ACCESS_HEADER, REQUEST_HEADER, redact, validId, validateAppId, withContext } from "./shared.js";
|
|
4
|
+
export function createLogger(options) {
|
|
5
|
+
validateAppId(options.appId);
|
|
6
|
+
if (!/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9_-]*$/.test(options.serviceId) || !/^[a-zA-Z0-9_.:-]{1,64}$/.test(options.environment))
|
|
7
|
+
throw Error("Invalid service or environment");
|
|
8
|
+
const endpoint = options.endpoint ? new URL(options.endpoint) : undefined;
|
|
9
|
+
if (endpoint && (endpoint.username || endpoint.password || endpoint.search || endpoint.hash || (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && ["localhost", "127.0.0.1"].includes(endpoint.hostname)))))
|
|
10
|
+
throw Error("Invalid log endpoint");
|
|
11
|
+
if (!options.sink && (!endpoint || !options.token))
|
|
12
|
+
throw Error("Provide a log ingest endpoint/token or a sink");
|
|
13
|
+
const warn = (code, count) => { try {
|
|
14
|
+
(options.onWarning ?? (event => console.warn(JSON.stringify(event))))({ code, count });
|
|
15
|
+
}
|
|
16
|
+
catch { /* Warnings cannot interrupt work. */ } };
|
|
17
|
+
const context = new AsyncLocalStorage();
|
|
18
|
+
const anonymous = { appId: options.appId, identitySource: "anonymous" };
|
|
19
|
+
const queue = [];
|
|
20
|
+
const maxQueue = Number.isFinite(options.maxQueue) ? Math.max(1, Math.min(1000, Math.floor(options.maxQueue))) : 200;
|
|
21
|
+
let closing = false, stalled = false, pending;
|
|
22
|
+
const sink = options.sink ?? (async (batch, signal) => {
|
|
23
|
+
const response = await fetch(`${endpoint.href.replace(/\/$/, "")}/logs/batch`, { method: "POST", redirect: "error", signal,
|
|
24
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${options.token}` }, body: JSON.stringify(batch) });
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
await response.body?.cancel();
|
|
27
|
+
throw Object.assign(Error("Log ingest failed"), { retryable: response.status >= 500 || [408, 429].includes(response.status) });
|
|
28
|
+
}
|
|
29
|
+
const result = await response.json();
|
|
30
|
+
if (result?.success !== true || result?.data?.accepted !== batch.items.length)
|
|
31
|
+
throw Error("Log ingest incomplete");
|
|
32
|
+
});
|
|
33
|
+
const flush = () => {
|
|
34
|
+
if (pending)
|
|
35
|
+
return pending;
|
|
36
|
+
if (stalled)
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
pending = (async () => {
|
|
39
|
+
// Snapshot admission budget so a busy process cannot make flush endless.
|
|
40
|
+
let budget = queue.length * 3;
|
|
41
|
+
while (queue.length && budget > 0) {
|
|
42
|
+
const topic = queue[0].topic;
|
|
43
|
+
const selected = [];
|
|
44
|
+
let bytes = 512;
|
|
45
|
+
for (const item of queue) {
|
|
46
|
+
if (item.topic !== topic || selected.length >= 50 || bytes + item.bytes > 48000)
|
|
47
|
+
continue;
|
|
48
|
+
selected.push(item);
|
|
49
|
+
bytes += item.bytes;
|
|
50
|
+
}
|
|
51
|
+
for (const item of selected) {
|
|
52
|
+
queue.splice(queue.indexOf(item), 1);
|
|
53
|
+
item.attempts++;
|
|
54
|
+
}
|
|
55
|
+
budget -= selected.length;
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
let timeout;
|
|
58
|
+
const writing = Promise.resolve().then(() => sink({ serviceId: options.serviceId, environment: options.environment, topic, items: selected.map(item => item.entry) }, controller.signal));
|
|
59
|
+
void writing.then(() => { stalled = false; }, () => { stalled = false; });
|
|
60
|
+
try {
|
|
61
|
+
await Promise.race([writing, new Promise((_, reject) => {
|
|
62
|
+
timeout = setTimeout(() => { stalled = true; controller.abort(); reject(Error("Log sink timed out")); }, 8000);
|
|
63
|
+
})]);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
warn("log_send_failed", selected.length);
|
|
67
|
+
for (const item of selected) {
|
|
68
|
+
if (!stalled && error?.retryable !== false && item.attempts < 3 && queue.length < maxQueue)
|
|
69
|
+
queue.push(item);
|
|
70
|
+
else
|
|
71
|
+
warn("log_dropped", 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
77
|
+
if (stalled)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
})().finally(() => { pending = undefined; });
|
|
81
|
+
return pending;
|
|
82
|
+
};
|
|
83
|
+
const timer = setInterval(() => { void flush(); }, Math.max(1000, options.flushIntervalMs ?? 5000));
|
|
84
|
+
timer.unref();
|
|
85
|
+
return {
|
|
86
|
+
getContext: () => context.getStore() ?? anonymous,
|
|
87
|
+
/** verifiedProfile comes from server authentication, never request headers/body. */
|
|
88
|
+
withRequest(request, verifiedProfile, run) {
|
|
89
|
+
if (verifiedProfile.profileId != null && !validId(verifiedProfile.profileId))
|
|
90
|
+
throw Error("Invalid verified profileId");
|
|
91
|
+
const accessId = request.headers.get(ACCESS_HEADER), requestId = request.headers.get(REQUEST_HEADER);
|
|
92
|
+
const scope = { appId: options.appId, ...(validId(accessId) ? { accessId } : {}),
|
|
93
|
+
...(verifiedProfile.profileId ? { profileId: verifiedProfile.profileId } : {}),
|
|
94
|
+
requestId: validId(requestId) ? requestId : randomUUID(), identitySource: verifiedProfile.profileId ? "authenticated" : "anonymous" };
|
|
95
|
+
return context.run(scope, () => run(scope));
|
|
96
|
+
},
|
|
97
|
+
append(topic, input) {
|
|
98
|
+
if (closing)
|
|
99
|
+
return;
|
|
100
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(topic))
|
|
101
|
+
throw Error("Invalid topic");
|
|
102
|
+
try {
|
|
103
|
+
const value = withContext(input, context.getStore() ?? anonymous);
|
|
104
|
+
const entry = { id: randomUUID(), time: value.time ?? Date.now(),
|
|
105
|
+
...(value.content !== undefined ? { content: redact(value.content) } : {}), data: redact(value.data) };
|
|
106
|
+
const bytes = new TextEncoder().encode(JSON.stringify(entry)).length;
|
|
107
|
+
if (!Number.isSafeInteger(entry.time) || entry.time < 0 || bytes > 32000) {
|
|
108
|
+
warn("log_dropped", 1);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (queue.length >= maxQueue) {
|
|
112
|
+
queue.shift();
|
|
113
|
+
warn("log_dropped", 1);
|
|
114
|
+
}
|
|
115
|
+
queue.push({ topic, entry, bytes, attempts: 0 });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
warn("log_dropped", 1);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
flush,
|
|
122
|
+
async close() { closing = true; clearInterval(timer); await flush(); if (queue.length && !stalled)
|
|
123
|
+
await flush(); if (queue.length) {
|
|
124
|
+
warn("log_dropped", queue.length);
|
|
125
|
+
queue.length = 0;
|
|
126
|
+
} },
|
|
127
|
+
};
|
|
128
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type BrowserLogger, type BrowserLoggerOptions } from "./browser.js";
|
|
3
|
+
export type ReactLogger = Pick<BrowserLogger, "append" | "fetch" | "flush">;
|
|
4
|
+
/** Keep options stable for the Provider's lifetime. Update identity through profileId. */
|
|
5
|
+
export declare function LogProvider({ options, profileId, children }: {
|
|
6
|
+
options: Omit<BrowserLoggerOptions, "profileId">;
|
|
7
|
+
profileId?: string | null;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<ReactLogger | null>>;
|
|
10
|
+
export declare function useLog(): ReactLogger;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { createLogger } from "./browser.js";
|
|
3
|
+
const Context = createContext(null);
|
|
4
|
+
/** Keep options stable for the Provider's lifetime. Update identity through profileId. */
|
|
5
|
+
export function LogProvider({ options, profileId, children }) {
|
|
6
|
+
const initial = useRef(options);
|
|
7
|
+
const identity = useRef(profileId);
|
|
8
|
+
const logger = useRef(undefined);
|
|
9
|
+
const generation = useRef(0);
|
|
10
|
+
const ready = useMemo(() => {
|
|
11
|
+
let resolve;
|
|
12
|
+
const promise = new Promise(done => { resolve = done; });
|
|
13
|
+
return { promise, resolve };
|
|
14
|
+
}, []);
|
|
15
|
+
const buffered = useRef([]);
|
|
16
|
+
identity.current = profileId;
|
|
17
|
+
const value = useMemo(() => ({
|
|
18
|
+
append(topic, input) {
|
|
19
|
+
if (logger.current) {
|
|
20
|
+
logger.current.setProfileId(identity.current);
|
|
21
|
+
logger.current.append(topic, input);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
if (buffered.current.length >= 200)
|
|
25
|
+
buffered.current.shift();
|
|
26
|
+
buffered.current.push({ topic, input, profileId: identity.current });
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async fetch(input, init) {
|
|
30
|
+
const instance = logger.current ?? await ready.promise;
|
|
31
|
+
instance?.setProfileId(identity.current);
|
|
32
|
+
return instance ? instance.fetch(input, init) : fetch(input, init);
|
|
33
|
+
},
|
|
34
|
+
flush() { return logger.current?.flush() ?? Promise.resolve(); },
|
|
35
|
+
}), []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const current = ++generation.current;
|
|
38
|
+
// StrictMode's first setup is cleaned up before this microtask. Only the
|
|
39
|
+
// committed mount installs browser observers and emits the initial PV.
|
|
40
|
+
queueMicrotask(() => {
|
|
41
|
+
if (generation.current !== current)
|
|
42
|
+
return;
|
|
43
|
+
let instance;
|
|
44
|
+
try {
|
|
45
|
+
instance = createLogger({ ...initial.current, profileId: identity.current });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
buffered.current.length = 0;
|
|
49
|
+
ready.resolve(undefined);
|
|
50
|
+
console.warn("PMate telemetry initialization failed");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
logger.current = instance;
|
|
54
|
+
ready.resolve(instance);
|
|
55
|
+
for (const item of buffered.current.splice(0)) {
|
|
56
|
+
instance.setProfileId(item.profileId);
|
|
57
|
+
instance.append(item.topic, item.input);
|
|
58
|
+
}
|
|
59
|
+
instance.setProfileId(identity.current);
|
|
60
|
+
});
|
|
61
|
+
return () => {
|
|
62
|
+
generation.current++;
|
|
63
|
+
const instance = logger.current;
|
|
64
|
+
logger.current = undefined;
|
|
65
|
+
if (instance)
|
|
66
|
+
void instance.close();
|
|
67
|
+
const disposed = generation.current;
|
|
68
|
+
queueMicrotask(() => { if (generation.current === disposed)
|
|
69
|
+
ready.resolve(undefined); });
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
useEffect(() => { logger.current?.setProfileId(profileId); }, [profileId]);
|
|
73
|
+
return createElement(Context.Provider, { value }, children);
|
|
74
|
+
}
|
|
75
|
+
export function useLog() {
|
|
76
|
+
const logger = useContext(Context);
|
|
77
|
+
if (!logger)
|
|
78
|
+
throw Error("useLog requires LogProvider");
|
|
79
|
+
return logger;
|
|
80
|
+
}
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const SCHEMA_VERSION = 1;
|
|
2
|
+
export declare const ACCESS_HEADER = "x-pmate-access-id";
|
|
3
|
+
export declare const PROFILE_HEADER = "x-pmate-profile-id";
|
|
4
|
+
export declare const REQUEST_HEADER = "x-request-id";
|
|
5
|
+
export type LogContext = {
|
|
6
|
+
appId: string;
|
|
7
|
+
accessId?: string;
|
|
8
|
+
profileId?: string;
|
|
9
|
+
requestId?: string;
|
|
10
|
+
identitySource: "browser" | "authenticated" | "anonymous";
|
|
11
|
+
};
|
|
12
|
+
export type LogInput = {
|
|
13
|
+
time?: number;
|
|
14
|
+
content?: string;
|
|
15
|
+
data?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type LogEntry = LogInput & {
|
|
18
|
+
id: string;
|
|
19
|
+
time: number;
|
|
20
|
+
};
|
|
21
|
+
export declare const validId: (value: unknown) => value is string;
|
|
22
|
+
export declare function validateAppId(appId: string): void;
|
|
23
|
+
export declare function withContext(input: LogInput | string, context: LogContext): LogInput;
|
|
24
|
+
export declare function redact(value: unknown, depth?: number, seen?: WeakSet<object>): unknown;
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const SCHEMA_VERSION = 1;
|
|
2
|
+
export const ACCESS_HEADER = "x-pmate-access-id";
|
|
3
|
+
export const PROFILE_HEADER = "x-pmate-profile-id";
|
|
4
|
+
export const REQUEST_HEADER = "x-request-id";
|
|
5
|
+
export const validId = (value) => typeof value === "string" && /^[a-zA-Z0-9_.:@-]{1,128}$/.test(value);
|
|
6
|
+
export function validateAppId(appId) {
|
|
7
|
+
if (!/^[a-zA-Z0-9_.@-]+:[a-zA-Z0-9_.-]+$/.test(appId))
|
|
8
|
+
throw Error("Invalid appId");
|
|
9
|
+
}
|
|
10
|
+
export function withContext(input, context) {
|
|
11
|
+
const value = typeof input === "string" ? { content: input } : input;
|
|
12
|
+
// Reserved identity fields cannot be supplied by an individual event.
|
|
13
|
+
const { appId: _app, accessId: _access, profileId: _profile, requestId: _request, identitySource: _source, ...data } = value.data ?? {};
|
|
14
|
+
return { ...value, data: { ...data, ...context, contextVersion: SCHEMA_VERSION } };
|
|
15
|
+
}
|
|
16
|
+
const sensitive = /^(authorization|cookie|setcookie|password|secret|token|accesstoken|refreshtoken|accesskey|apikey|privatekey|requestbody|responsebody)$/i;
|
|
17
|
+
export function redact(value, depth = 0, seen = new WeakSet()) {
|
|
18
|
+
if (typeof value === "string")
|
|
19
|
+
return value.slice(0, 4000).replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]");
|
|
20
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
21
|
+
return value;
|
|
22
|
+
if (typeof value !== "object")
|
|
23
|
+
return String(value);
|
|
24
|
+
if (depth >= 6 || seen.has(value))
|
|
25
|
+
return "[truncated]";
|
|
26
|
+
seen.add(value);
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.slice(0, 50).map(item => redact(item, depth + 1, seen));
|
|
29
|
+
return Object.fromEntries(Object.entries(value).slice(0, 100).filter(([key]) => !sensitive.test(key.replace(/[_-]/g, ""))).map(([key, item]) => [key, redact(item, depth + 1, seen)]));
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/log-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PMate browser, React and Node logs with access and profile context",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/shared.d.ts",
|
|
14
|
+
"import": "./dist/shared.js"
|
|
15
|
+
},
|
|
16
|
+
"./browser": {
|
|
17
|
+
"types": "./dist/browser.d.ts",
|
|
18
|
+
"import": "./dist/browser.js"
|
|
19
|
+
},
|
|
20
|
+
"./react": {
|
|
21
|
+
"types": "./dist/react.d.ts",
|
|
22
|
+
"import": "./dist/react.js"
|
|
23
|
+
},
|
|
24
|
+
"./node": {
|
|
25
|
+
"types": "./dist/node.d.ts",
|
|
26
|
+
"import": "./dist/node.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@pmate/log": "0.1.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.10.2",
|
|
42
|
+
"@types/react": "^19.2.7",
|
|
43
|
+
"@types/react-dom": "^19.2.3",
|
|
44
|
+
"jsdom": "^26.1.0",
|
|
45
|
+
"react": "^19.1.1",
|
|
46
|
+
"react-dom": "^19.1.1",
|
|
47
|
+
"typescript": "^5.8.3",
|
|
48
|
+
"vitest": "^4.0.17",
|
|
49
|
+
"web-vitals": "^5.1.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc -p tsconfig.json",
|
|
56
|
+
"test": "vitest run"
|
|
57
|
+
}
|
|
6
58
|
}
|