@pmate/log-sdk 0.4.0 → 0.6.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 +101 -14
- package/dist/accessTransport.js +1 -1
- package/dist/blockchainSink.js +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +3 -3
- package/dist/event.d.ts +24 -0
- package/dist/event.js +97 -0
- package/dist/node.d.ts +2 -2
- package/dist/node.js +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +9 -9
- package/dist/shared.js +1 -1
- package/dist/web.d.ts +35 -0
- package/dist/web.js +56 -0
- package/dist/writer.js +2 -2
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -1,33 +1,120 @@
|
|
|
1
1
|
# @pmate/log-sdk
|
|
2
2
|
|
|
3
|
+
## Web: initialize once, then report user actions
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { web } from '@pmate/log-sdk/web'
|
|
7
|
+
|
|
8
|
+
web.init({
|
|
9
|
+
key: 'KEY_FROM_DEVELOPERS',
|
|
10
|
+
appId: 'pmate:hello',
|
|
11
|
+
auto: { pv: true, webVitals: true }, // defaults to both enabled
|
|
12
|
+
})
|
|
13
|
+
web.track('signup.clicked', { entry: 'home' })
|
|
14
|
+
web.identify('profile-alice') // optional self-reported profile selection
|
|
15
|
+
web.reset() // clear profile on logout; preserve the anonymous visit
|
|
16
|
+
await web.flush()
|
|
17
|
+
// await web.close() on application teardown, not on every route
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Create a Web key with `pv`, `behavior` and `vitals` at https://dev.pmate.chat/log-keys.
|
|
21
|
+
Existing keys retain their original scopes. If your key excludes a topic, disable
|
|
22
|
+
its automatic collection. `web.pageView()` records a manual visit; turn off
|
|
23
|
+
`auto.pv` when your router explicitly records visits to avoid double counting.
|
|
24
|
+
The SDK strips URL query strings/fragments and records available CLS, FCP, INP,
|
|
25
|
+
LCP and TTFB separately from PV. SPA changes never move delayed document metrics
|
|
26
|
+
to a new route. Metrics remain runtime logs (48h); user events remain event logs (1y).
|
|
27
|
+
|
|
28
|
+
`init` is synchronous local configuration; it makes no registration or login request.
|
|
29
|
+
The default endpoint is `https://log-api.pmate.chat`; an optional `endpoint` supports
|
|
30
|
+
another deployment (HTTPS, or HTTP localhost for tests). Same-configuration init
|
|
31
|
+
is idempotent, changed configuration requires awaiting `close()`. Pre-init calls
|
|
32
|
+
are ignored with a one-time warning. Importing is SSR-safe; initialize in a client
|
|
33
|
+
lifecycle. `createWebLogger()` provides an isolated instance for tests or multiple
|
|
34
|
+
independent applications; normally use the singleton `web`.
|
|
35
|
+
|
|
36
|
+
Properties/identity are captured at event time. `identify` does not sign in a user,
|
|
37
|
+
and never takes an auth token. The in-memory queue holds 200 entries by default,
|
|
38
|
+
batches at most 50 entries/48 KiB, attempts an entry at most 3 times using the
|
|
39
|
+
original ID/time, and flushes every 5 seconds and on hide. `flush` drains all topics;
|
|
40
|
+
`close` stops listeners/admission and drains. Delivery is best effort; no disk queue.
|
|
41
|
+
Use explicit behavior events; do not send personal data in properties or path names.
|
|
42
|
+
|
|
43
|
+
Native Swift/Kotlin SDKs in `parrot-native` expose the equivalent
|
|
44
|
+
`PmateLog.initSDK`, `screen`, `track`, `identify`, `reset`, `flush`, `close` API,
|
|
45
|
+
with automatic app foreground/background events. Their examples are generated by
|
|
46
|
+
the Key console for the selected platform.
|
|
47
|
+
|
|
3
48
|
## Background
|
|
4
49
|
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
50
|
|
|
6
|
-
##
|
|
51
|
+
## Runtime and Event APIs
|
|
7
52
|
|
|
8
53
|
Classification follows event meaning, not the runtime or the presence of HTTP.
|
|
9
|
-
The
|
|
54
|
+
The lower-level browser/React APIs expose separate runtime/event scopes. The Web scenario entry provides direct pageView/track methods.
|
|
10
55
|
|
|
11
56
|
| API | Meaning | Chain | Retention |
|
|
12
57
|
| --- | --- | --- | --- |
|
|
13
|
-
| `log.
|
|
14
|
-
| `log.
|
|
15
|
-
| `log.
|
|
58
|
+
| `log.runtime.append(topic, entry)` / `log.runtime.captureError(error)` | Program diagnostics, HTTP requests, bot activity, auth, heartbeat, polling, deployment, console, vitals and crashes | `app-logs` | 48 hours |
|
|
59
|
+
| `log.event.pageView()` | Explicit browser page visit, with current pathname and generated page-view ID | `web-logs` | 1 year |
|
|
60
|
+
| `log.event.track(name, properties)` | Explicitly defined user behavior | `web-logs` | 1 year |
|
|
16
61
|
|
|
17
|
-
React exposes both scopes. Node exposes only `
|
|
62
|
+
React exposes both scopes. Node exposes only `runtime`; `runtime.append` rejects `pv`
|
|
18
63
|
and `behavior` topics. Callers cannot set the category or destination chain.
|
|
19
64
|
Retention is enforced by chain configuration, not by the SDK queue.
|
|
20
|
-
Automatic page views use the
|
|
21
|
-
|
|
65
|
+
Automatic page views use the Event route. Browser errors, console and vitals use
|
|
66
|
+
Runtime even though they originate in the browser. Intake grants must explicitly
|
|
22
67
|
allow each category/topic pair.
|
|
23
68
|
|
|
24
69
|
```tsx
|
|
25
70
|
const log = useLog() // @pmate/log-sdk/react
|
|
26
|
-
log.
|
|
27
|
-
log.
|
|
28
|
-
log.
|
|
71
|
+
log.runtime.append("application", { data: { event: "sync.failed" } })
|
|
72
|
+
log.event.track("signup.clicked", { button: "signup" })
|
|
73
|
+
log.event.pageView() // use when manually recording a page visit
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Public event keys and initialization
|
|
77
|
+
|
|
78
|
+
Create a log intake key at https://dev.pmate.chat/log-keys. Bind a registered service,
|
|
79
|
+
application, environment, platform, allowed topics and quota. Creation is the only
|
|
80
|
+
provisioning step: there is no per-topic init or manual table creation. Instantiate
|
|
81
|
+
the SDK once per application. The key determines service/environment server-side;
|
|
82
|
+
no user login or account token is needed to append events. It cannot read logs.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { createLogger } from '@pmate/log-sdk/browser'
|
|
86
|
+
const log = createLogger({
|
|
87
|
+
endpoint: 'https://log-api.pmate.chat',
|
|
88
|
+
eventKey: 'KEY_FROM_DEVELOPERS', appId: 'pmate:hello',
|
|
89
|
+
})
|
|
90
|
+
log.event.track('signup.clicked', { source: 'home' })
|
|
91
|
+
await log.flush()
|
|
29
92
|
```
|
|
30
93
|
|
|
94
|
+
For native JavaScript clients, use `@pmate/log-sdk/event` with the same options plus
|
|
95
|
+
`platform: 'ios'` or `'android'`. It has no browser/Node dependencies; call
|
|
96
|
+
`log.event.pageView('/screen')` explicitly. Custom clients can POST
|
|
97
|
+
`{category:'event',topic:'behavior',items:[{id,time,data:{appId,platform,event}}]}`
|
|
98
|
+
to `/event/logs/batch` with `x-pmate-log-key`. IDs and timestamps must be retained
|
|
99
|
+
on retries. Profile/access IDs are optional untrusted client claims.
|
|
100
|
+
|
|
101
|
+
Web keys may restrict exact HTTP(S) Origins. Native keys need no Origin. Quotas
|
|
102
|
+
are per active intake process (production uses one process); no distributed quota
|
|
103
|
+
is claimed. Disable/rotation writes are confirmed against the Store index before
|
|
104
|
+
success; new requests then reject the old key. In-flight writes may complete.
|
|
105
|
+
Storage errors fail closed. Plaintext keys are returned once; list responses omit
|
|
106
|
+
digests. The current single management writer serializes mutations; deploying
|
|
107
|
+
multiple management writers requires storage-level concurrency control first.
|
|
108
|
+
|
|
109
|
+
An `eventKey` only sends behavior/PV. Automatic runtime diagnostics require the
|
|
110
|
+
existing scoped `key` plus serviceId/environment; diagnostics are not routed through
|
|
111
|
+
the event key. Both keys can be provided to one browser/React logger.
|
|
112
|
+
|
|
113
|
+
The SDK now exposes only `runtime` / `event`. Update previous `.app` / `.web`
|
|
114
|
+
call sites before upgrading. Existing static grants and old request categories
|
|
115
|
+
are explicitly translated at legacy HTTP ingress during migration; physical
|
|
116
|
+
`app-logs` / `web-logs` chains and historical rows are unchanged.
|
|
117
|
+
|
|
31
118
|
## Identity
|
|
32
119
|
`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.
|
|
33
120
|
|
|
@@ -46,7 +133,7 @@ function App({ profileId }) {
|
|
|
46
133
|
}
|
|
47
134
|
function Content() {
|
|
48
135
|
const log = useLog()
|
|
49
|
-
return <button onClick={() => log.
|
|
136
|
+
return <button onClick={() => log.runtime.append('application', {data:{event:'feature.opened'}})}>Open</button>
|
|
50
137
|
}
|
|
51
138
|
```
|
|
52
139
|
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.
|
|
@@ -76,7 +163,7 @@ const log = createLogger({
|
|
|
76
163
|
endpoint: 'https://log-api.pmate.chat', token: process.env.LOG_INGEST_TOKEN,
|
|
77
164
|
})
|
|
78
165
|
await log.withRequest(request, {profileId: authenticatedProfile?.id}, async scope => {
|
|
79
|
-
log.
|
|
166
|
+
log.runtime.append('application', {data:{event:'http.request', requestId:scope.requestId}})
|
|
80
167
|
// business work; report completion/error without raw bodies
|
|
81
168
|
})
|
|
82
169
|
await log.close()
|
|
@@ -111,7 +198,7 @@ The envelope `id` is the event deduplication key. PV counts pv events; sessions
|
|
|
111
198
|
## Error and crash diagnostics
|
|
112
199
|
|
|
113
200
|
Caught exceptions need explicit reporting; a global rejection listener cannot see
|
|
114
|
-
an exception already handled by application code. Use `log.
|
|
201
|
+
an exception already handled by application code. Use `log.runtime.captureError(error,
|
|
115
202
|
{ operation: 'chat.send' })` on browser, React and Node loggers. Error records use
|
|
116
203
|
the `error` topic and include bounded message/stack diagnostics with URL queries
|
|
117
204
|
removed. Applications must redact domain-specific sensitive strings before
|
package/dist/accessTransport.js
CHANGED
|
@@ -32,7 +32,7 @@ export function createAppAccessLogger(options) {
|
|
|
32
32
|
...(status !== undefined ? { status } : {}), ...(durationMs !== undefined ? { durationMs } : {}),
|
|
33
33
|
...(outcome ? { outcome } : {}) } };
|
|
34
34
|
const request = new Request("https://logging.invalid", { headers: { "x-request-id": requestId } });
|
|
35
|
-
const entry = logger.withRequest(request, {}, () => logger.
|
|
35
|
+
const entry = logger.withRequest(request, {}, () => logger.runtime.append("application", {
|
|
36
36
|
time: safe.time, content: `${method} ${route}${status === undefined ? "" : ` ${status}`}`,
|
|
37
37
|
data: { ...safe.data, event: safe.type, level: safe.level },
|
|
38
38
|
}));
|
package/dist/blockchainSink.js
CHANGED
|
@@ -4,7 +4,7 @@ import { blockchainEndpoint } from "./blockchainEndpoint.js";
|
|
|
4
4
|
export function createAppLogSink(options) {
|
|
5
5
|
const target = `${blockchainEndpoint(options.blockchainUrl)}/chains/app-logs/logs`;
|
|
6
6
|
return async (batch, signal) => {
|
|
7
|
-
if (batch.category !== "
|
|
7
|
+
if (batch.category !== "runtime")
|
|
8
8
|
throw Object.assign(Error("App sink rejects web logs"), { retryable: false });
|
|
9
9
|
const logs = batch.items.map(item => {
|
|
10
10
|
const { kind, topic, v, data } = buildLogEnvelope(batch.serviceId, batch.topic, batch.environment, item);
|
package/dist/browser.d.ts
CHANGED
|
@@ -9,11 +9,11 @@ export type BrowserLoggerOptions = BrowserLogOptions & {
|
|
|
9
9
|
export type BrowserLogger = ReturnType<typeof createLogger>;
|
|
10
10
|
/** Own one instance per application document. Server imports never touch window. */
|
|
11
11
|
export declare function createLogger(options: BrowserLoggerOptions): {
|
|
12
|
-
|
|
12
|
+
runtime: {
|
|
13
13
|
append(topic: string, input: LogInput | string): void;
|
|
14
14
|
captureError(error: unknown, context?: Parameters<typeof errorInput>[1]): void;
|
|
15
15
|
};
|
|
16
|
-
|
|
16
|
+
event: {
|
|
17
17
|
pageView(): void;
|
|
18
18
|
track(name: string, properties?: Record<string, unknown>): void;
|
|
19
19
|
};
|
package/dist/browser.js
CHANGED
|
@@ -62,10 +62,10 @@ export function createLogger(options) {
|
|
|
62
62
|
throw error;
|
|
63
63
|
}
|
|
64
64
|
return {
|
|
65
|
-
|
|
65
|
+
runtime: {
|
|
66
66
|
append(topic, input) {
|
|
67
67
|
if (["pv", "behavior"].includes(topic))
|
|
68
|
-
throw Error("PV and behavior require the
|
|
68
|
+
throw Error("PV and behavior require the event API");
|
|
69
69
|
if (!closed) {
|
|
70
70
|
access.touch();
|
|
71
71
|
transport.append(topic, input);
|
|
@@ -75,7 +75,7 @@ export function createLogger(options) {
|
|
|
75
75
|
this.append("error", errorInput(error, context));
|
|
76
76
|
},
|
|
77
77
|
},
|
|
78
|
-
|
|
78
|
+
event: {
|
|
79
79
|
pageView() { if (!closed) {
|
|
80
80
|
access.touch();
|
|
81
81
|
transport.pageView();
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type EventLoggerOptions = {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
eventKey: string;
|
|
4
|
+
appId: string;
|
|
5
|
+
platform: "web" | "ios" | "android";
|
|
6
|
+
accessId?: string;
|
|
7
|
+
profileId?: string;
|
|
8
|
+
maxQueue?: number;
|
|
9
|
+
flushIntervalMs?: number;
|
|
10
|
+
fetcher?: typeof fetch;
|
|
11
|
+
onWarning?: (event: {
|
|
12
|
+
code: "event_dropped";
|
|
13
|
+
count: number;
|
|
14
|
+
}) => void;
|
|
15
|
+
};
|
|
16
|
+
/** Fetch-only event client for native JavaScript runtimes and trusted explicit callers. No DOM or Node imports. */
|
|
17
|
+
export declare function createLogger(options: EventLoggerOptions): {
|
|
18
|
+
event: {
|
|
19
|
+
track(name: string, properties?: Record<string, unknown>): void;
|
|
20
|
+
pageView(path: string): void;
|
|
21
|
+
};
|
|
22
|
+
flush: () => Promise<void>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
};
|
package/dist/event.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { redact, validateAppId, validId } from "./shared.js";
|
|
2
|
+
/** Fetch-only event client for native JavaScript runtimes and trusted explicit callers. No DOM or Node imports. */
|
|
3
|
+
export function createLogger(options) {
|
|
4
|
+
validateAppId(options.appId);
|
|
5
|
+
const url = new URL(options.endpoint);
|
|
6
|
+
if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))))
|
|
7
|
+
throw Error("Invalid event endpoint");
|
|
8
|
+
if (!/^lk_[a-f0-9]{24}_[a-zA-Z0-9_-]{43}$/.test(options.eventKey) || !["web", "ios", "android"].includes(options.platform))
|
|
9
|
+
throw Error("Invalid event configuration");
|
|
10
|
+
if ((options.accessId !== undefined && !validId(options.accessId)) || (options.profileId !== undefined && !validId(options.profileId)))
|
|
11
|
+
throw Error("Invalid event identity");
|
|
12
|
+
const max = Number.isSafeInteger(options.maxQueue) ? Math.max(1, Math.min(1000, options.maxQueue)) : 200;
|
|
13
|
+
const queue = [];
|
|
14
|
+
let closed = false, pending, closePromise;
|
|
15
|
+
const warn = () => { try {
|
|
16
|
+
options.onWarning?.({ code: "event_dropped", count: 1 });
|
|
17
|
+
}
|
|
18
|
+
catch { /* Best effort. */ } };
|
|
19
|
+
const append = (topic, data) => {
|
|
20
|
+
if (closed)
|
|
21
|
+
return;
|
|
22
|
+
const entry = { id: crypto.randomUUID(), time: Date.now(), data: redact({ ...data, appId: options.appId, platform: options.platform, ...(options.accessId ? { accessId: options.accessId } : {}), ...(options.profileId ? { profileId: options.profileId } : {}), identitySource: "client" }) };
|
|
23
|
+
const bytes = new TextEncoder().encode(JSON.stringify(entry)).length;
|
|
24
|
+
if (bytes > 32000) {
|
|
25
|
+
warn();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (queue.length >= max) {
|
|
29
|
+
queue.shift();
|
|
30
|
+
warn();
|
|
31
|
+
}
|
|
32
|
+
queue.push({ topic, entry, attempts: 0, bytes });
|
|
33
|
+
};
|
|
34
|
+
const flush = () => {
|
|
35
|
+
if (pending)
|
|
36
|
+
return pending;
|
|
37
|
+
pending = (async () => {
|
|
38
|
+
let budget = queue.length * 3;
|
|
39
|
+
while (queue.length && budget > 0) {
|
|
40
|
+
const topic = queue[0].topic, batch = [];
|
|
41
|
+
let bytes = 512;
|
|
42
|
+
for (const item of queue)
|
|
43
|
+
if (item.topic === topic && batch.length < 50 && bytes + item.bytes <= 48000) {
|
|
44
|
+
batch.push(item);
|
|
45
|
+
bytes += item.bytes;
|
|
46
|
+
}
|
|
47
|
+
for (const item of batch) {
|
|
48
|
+
queue.splice(queue.indexOf(item), 1);
|
|
49
|
+
item.attempts++;
|
|
50
|
+
}
|
|
51
|
+
budget -= batch.length;
|
|
52
|
+
let retry = true;
|
|
53
|
+
try {
|
|
54
|
+
const response = await (options.fetcher ?? fetch)(url.href.replace(/\/$/, "") + "/event/logs/batch", { method: "POST", credentials: "omit", redirect: "error", signal: AbortSignal.timeout(8000), headers: { "Content-Type": "application/json", "x-pmate-log-key": options.eventKey }, body: JSON.stringify({ category: "event", topic, items: batch.map(item => item.entry) }) });
|
|
55
|
+
retry = response.status >= 500 || [408, 429].includes(response.status);
|
|
56
|
+
if (response.ok) {
|
|
57
|
+
const result = await response.json();
|
|
58
|
+
retry = result?.success !== true || result?.data?.accepted !== batch.length;
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
await response.body?.cancel();
|
|
62
|
+
}
|
|
63
|
+
catch { /* Retry original IDs. */ }
|
|
64
|
+
if (retry)
|
|
65
|
+
for (const item of batch) {
|
|
66
|
+
if (item.attempts < 3 && queue.length < max)
|
|
67
|
+
queue.push(item);
|
|
68
|
+
else
|
|
69
|
+
warn();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})().finally(() => { pending = undefined; });
|
|
73
|
+
return pending;
|
|
74
|
+
};
|
|
75
|
+
const timer = setInterval(() => void flush(), Math.max(1000, options.flushIntervalMs ?? 5000));
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
return {
|
|
78
|
+
event: {
|
|
79
|
+
track(name, properties = {}) {
|
|
80
|
+
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name))
|
|
81
|
+
throw Error("Invalid behavior event");
|
|
82
|
+
append("behavior", { ...properties, event: name });
|
|
83
|
+
},
|
|
84
|
+
pageView(path) {
|
|
85
|
+
if (!path.startsWith("/") || path.length > 2048 || /[?#]/.test(path))
|
|
86
|
+
throw Error("Use a path without query or fragment");
|
|
87
|
+
append("pv", { path, pageViewId: crypto.randomUUID() });
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
flush,
|
|
91
|
+
close() { if (!closePromise) {
|
|
92
|
+
closed = true;
|
|
93
|
+
clearInterval(timer);
|
|
94
|
+
closePromise = flush();
|
|
95
|
+
} return closePromise; },
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/node.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { errorInput } from "./errors.js";
|
|
2
2
|
import { type LogContext, type LogEntry, type LogInput } from "./shared.js";
|
|
3
3
|
export type LogBatch = {
|
|
4
|
-
category: "
|
|
4
|
+
category: "runtime";
|
|
5
5
|
serviceId: string;
|
|
6
6
|
environment: string;
|
|
7
7
|
topic: string;
|
|
@@ -30,7 +30,7 @@ export type NodeLoggerOptions = {
|
|
|
30
30
|
}) => void;
|
|
31
31
|
};
|
|
32
32
|
export declare function createLogger(options: NodeLoggerOptions): {
|
|
33
|
-
|
|
33
|
+
runtime: {
|
|
34
34
|
captureError(error: unknown, context?: Parameters<typeof errorInput>[1]): void;
|
|
35
35
|
append(topic: string, input: LogInput | string): {
|
|
36
36
|
id: string;
|
package/dist/node.js
CHANGED
|
@@ -59,7 +59,7 @@ export function createLogger(options) {
|
|
|
59
59
|
budget -= selected.length;
|
|
60
60
|
const controller = new AbortController();
|
|
61
61
|
let timeout;
|
|
62
|
-
const writing = Promise.resolve().then(() => sink({ category: "
|
|
62
|
+
const writing = Promise.resolve().then(() => sink({ category: "runtime", serviceId: options.serviceId, environment: options.environment, topic, items: selected.map(item => item.entry) }, controller.signal));
|
|
63
63
|
void writing.then(() => { stalled = false; }, () => { stalled = false; });
|
|
64
64
|
try {
|
|
65
65
|
await Promise.race([writing, new Promise((_, reject) => {
|
|
@@ -87,13 +87,13 @@ export function createLogger(options) {
|
|
|
87
87
|
const timer = setInterval(() => { void flush(); }, Math.max(1000, options.flushIntervalMs ?? 5000));
|
|
88
88
|
timer.unref();
|
|
89
89
|
return {
|
|
90
|
-
|
|
90
|
+
runtime: {
|
|
91
91
|
captureError(error, context) {
|
|
92
92
|
this.append("error", errorInput(error, context));
|
|
93
93
|
},
|
|
94
94
|
append(topic, input) {
|
|
95
95
|
if (["pv", "behavior"].includes(topic))
|
|
96
|
-
throw Error("PV and behavior require the
|
|
96
|
+
throw Error("PV and behavior require the event API");
|
|
97
97
|
if (closing)
|
|
98
98
|
return;
|
|
99
99
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(topic))
|
package/dist/react.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
2
|
import { type BrowserLogger, type BrowserLoggerOptions } from "./browser.js";
|
|
3
|
-
export type ReactLogger = Pick<BrowserLogger, "
|
|
3
|
+
export type ReactLogger = Pick<BrowserLogger, "runtime" | "event" | "fetch" | "flush">;
|
|
4
4
|
/** Keep options stable for the Provider's lifetime. Update identity through profileId. */
|
|
5
5
|
export declare function LogProvider({ options, profileId, children }: {
|
|
6
6
|
options: Omit<BrowserLoggerOptions, "profileId">;
|
package/dist/react.js
CHANGED
|
@@ -16,26 +16,26 @@ export function LogProvider({ options, profileId, children }) {
|
|
|
16
16
|
const buffered = useRef([]);
|
|
17
17
|
identity.current = profileId;
|
|
18
18
|
const value = useMemo(() => ({
|
|
19
|
-
|
|
19
|
+
event: {
|
|
20
20
|
track(name, properties) {
|
|
21
21
|
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name))
|
|
22
22
|
throw Error("Invalid behavior event");
|
|
23
23
|
const profile = identity.current;
|
|
24
|
-
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.
|
|
24
|
+
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.event.track(name, properties); });
|
|
25
25
|
},
|
|
26
26
|
pageView() {
|
|
27
27
|
const profile = identity.current;
|
|
28
|
-
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.
|
|
28
|
+
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.event.pageView(); });
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
|
-
|
|
32
|
-
captureError(error, context) { value.
|
|
31
|
+
runtime: {
|
|
32
|
+
captureError(error, context) { value.runtime.append("error", errorInput(error, context)); },
|
|
33
33
|
append(topic, input) {
|
|
34
34
|
if (["pv", "behavior"].includes(topic))
|
|
35
|
-
throw Error("PV and behavior require the
|
|
35
|
+
throw Error("PV and behavior require the event API");
|
|
36
36
|
if (logger.current) {
|
|
37
37
|
logger.current.setProfileId(identity.current);
|
|
38
|
-
logger.current.
|
|
38
|
+
logger.current.runtime.append(topic, input);
|
|
39
39
|
}
|
|
40
40
|
else {
|
|
41
41
|
if (buffered.current.length >= 200)
|
|
@@ -72,7 +72,7 @@ export function LogProvider({ options, profileId, children }) {
|
|
|
72
72
|
ready.resolve(instance);
|
|
73
73
|
for (const item of buffered.current.splice(0)) {
|
|
74
74
|
instance.setProfileId(item.profileId);
|
|
75
|
-
instance.
|
|
75
|
+
instance.runtime.append(item.topic, item.input);
|
|
76
76
|
}
|
|
77
77
|
instance.setProfileId(identity.current);
|
|
78
78
|
});
|
|
@@ -103,7 +103,7 @@ export class LogErrorBoundary extends Component {
|
|
|
103
103
|
static getDerivedStateFromError() { return { failed: true }; }
|
|
104
104
|
componentDidCatch(error, info) {
|
|
105
105
|
try {
|
|
106
|
-
this.context?.
|
|
106
|
+
this.context?.runtime.append("error", errorInput(error, { operation: "react.render", componentStack: info.componentStack ?? undefined, fatal: true }));
|
|
107
107
|
void this.context?.flush().catch(() => { });
|
|
108
108
|
}
|
|
109
109
|
catch { /* Reporting must not crash the fallback. */ }
|
package/dist/shared.js
CHANGED
|
@@ -13,7 +13,7 @@ export function withContext(input, context) {
|
|
|
13
13
|
const { appId: _app, accessId: _access, profileId: _profile, requestId: _request, identitySource: _source, ...data } = value.data ?? {};
|
|
14
14
|
return { ...value, data: { ...data, ...context, contextVersion: SCHEMA_VERSION } };
|
|
15
15
|
}
|
|
16
|
-
const sensitive = /^(authorization|cookie|setcookie|password|secret|token|accesstoken|refreshtoken|accesskey|apikey|privatekey|requestbody|responsebody)$/i;
|
|
16
|
+
const sensitive = /^(authorization|cookie|setcookie|password|secret|token|accesstoken|refreshtoken|accesskey|apikey|privatekey|eventkey|logkey|requestbody|responsebody)$/i;
|
|
17
17
|
export function redact(value, depth = 0, seen = new WeakSet()) {
|
|
18
18
|
if (value === undefined)
|
|
19
19
|
return undefined;
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type WebLogOptions = {
|
|
2
|
+
key: string;
|
|
3
|
+
appId: string;
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
/** Automatic page visits (including SPA navigation) and document metrics. Both default to true. */
|
|
6
|
+
auto?: {
|
|
7
|
+
pv?: boolean;
|
|
8
|
+
webVitals?: boolean;
|
|
9
|
+
};
|
|
10
|
+
flushIntervalMs?: number;
|
|
11
|
+
maxQueue?: number;
|
|
12
|
+
};
|
|
13
|
+
/** One lifecycle per browser application. Importing this entry is safe during SSR. */
|
|
14
|
+
export declare function createWebLogger(): {
|
|
15
|
+
init(options: WebLogOptions): /*elided*/ any;
|
|
16
|
+
pageView(): void;
|
|
17
|
+
track(name: string, properties?: Record<string, unknown>): void;
|
|
18
|
+
/** A self-reported profile ID, never a login credential. Applies only to future events. */
|
|
19
|
+
identify(profileId: string): void;
|
|
20
|
+
/** Clear profile selection on logout; the anonymous visit remains continuous. */
|
|
21
|
+
reset(): void;
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
export declare const web: {
|
|
26
|
+
init(options: WebLogOptions): /*elided*/ any;
|
|
27
|
+
pageView(): void;
|
|
28
|
+
track(name: string, properties?: Record<string, unknown>): void;
|
|
29
|
+
/** A self-reported profile ID, never a login credential. Applies only to future events. */
|
|
30
|
+
identify(profileId: string): void;
|
|
31
|
+
/** Clear profile selection on logout; the anonymous visit remains continuous. */
|
|
32
|
+
reset(): void;
|
|
33
|
+
flush(): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
};
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createLogger } from "./browser.js";
|
|
2
|
+
const defaults = (options) => ({
|
|
3
|
+
key: options.key, appId: options.appId, endpoint: options.endpoint ?? "https://log-api.pmate.chat",
|
|
4
|
+
auto: { pv: options.auto?.pv ?? true, webVitals: options.auto?.webVitals ?? true },
|
|
5
|
+
flushIntervalMs: options.flushIntervalMs ?? 5000, maxQueue: options.maxQueue ?? 200,
|
|
6
|
+
});
|
|
7
|
+
/** One lifecycle per browser application. Importing this entry is safe during SSR. */
|
|
8
|
+
export function createWebLogger() {
|
|
9
|
+
let logger;
|
|
10
|
+
let configuration, closing, warned = false;
|
|
11
|
+
const current = () => {
|
|
12
|
+
if (!logger && !warned) {
|
|
13
|
+
warned = true;
|
|
14
|
+
console.warn("PMate web log: call init before reporting events.");
|
|
15
|
+
}
|
|
16
|
+
return logger;
|
|
17
|
+
};
|
|
18
|
+
const api = {
|
|
19
|
+
init(options) {
|
|
20
|
+
const config = defaults(options), signature = JSON.stringify(config);
|
|
21
|
+
if (closing)
|
|
22
|
+
throw Error("Await web.close() before initializing again");
|
|
23
|
+
if (logger) {
|
|
24
|
+
if (configuration !== signature)
|
|
25
|
+
throw Error("Web log is already initialized; close before changing configuration");
|
|
26
|
+
return api;
|
|
27
|
+
}
|
|
28
|
+
logger = createLogger({ endpoint: config.endpoint, eventKey: config.key, appId: config.appId,
|
|
29
|
+
pageViews: config.auto.pv, vitals: config.auto.webVitals, errors: false,
|
|
30
|
+
flushIntervalMs: config.flushIntervalMs, maxQueue: config.maxQueue });
|
|
31
|
+
configuration = signature;
|
|
32
|
+
warned = false;
|
|
33
|
+
return api;
|
|
34
|
+
},
|
|
35
|
+
pageView() { current()?.event.pageView(); },
|
|
36
|
+
track(name, properties = {}) { current()?.event.track(name, properties); },
|
|
37
|
+
/** A self-reported profile ID, never a login credential. Applies only to future events. */
|
|
38
|
+
identify(profileId) { current()?.setProfileId(profileId); },
|
|
39
|
+
/** Clear profile selection on logout; the anonymous visit remains continuous. */
|
|
40
|
+
reset() { current()?.setProfileId(null); },
|
|
41
|
+
flush() { return current()?.flush() ?? Promise.resolve(); },
|
|
42
|
+
close() {
|
|
43
|
+
if (closing)
|
|
44
|
+
return closing;
|
|
45
|
+
const previous = logger;
|
|
46
|
+
logger = undefined;
|
|
47
|
+
configuration = undefined;
|
|
48
|
+
if (!previous)
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
closing = previous.close().finally(() => { closing = undefined; });
|
|
51
|
+
return closing;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return api;
|
|
55
|
+
}
|
|
56
|
+
export const web = createWebLogger();
|
package/dist/writer.js
CHANGED
|
@@ -4,7 +4,7 @@ import { redact } from "./shared.js";
|
|
|
4
4
|
/** Adapt an existing transactional chain appender without changing its commit semantics. */
|
|
5
5
|
export function createAppAppendSink(appendLogs) {
|
|
6
6
|
return async (batch) => {
|
|
7
|
-
if (batch.category !== "
|
|
7
|
+
if (batch.category !== "runtime")
|
|
8
8
|
throw Object.assign(Error("App sink rejects web logs"), { retryable: false });
|
|
9
9
|
await appendLogs(batch.items.map(entry => buildLogEnvelope(batch.serviceId, batch.topic, batch.environment, entry)));
|
|
10
10
|
};
|
|
@@ -24,7 +24,7 @@ export function createAppLogWriter(options) {
|
|
|
24
24
|
// Validate before invoking a caller's transactional appender.
|
|
25
25
|
for (const entry of entries)
|
|
26
26
|
buildLogEnvelope(options.serviceId, options.topic, options.environment, entry);
|
|
27
|
-
await options.sink({ category: "
|
|
27
|
+
await options.sink({ category: "runtime", serviceId: options.serviceId, environment: options.environment, topic: options.topic, items: entries }, AbortSignal.timeout(options.timeoutMs ?? 8000));
|
|
28
28
|
if (options.stdout !== false)
|
|
29
29
|
for (const entry of entries) {
|
|
30
30
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/log-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "PMate browser, React and Node logs with access and profile context",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -25,10 +25,18 @@
|
|
|
25
25
|
"./node": {
|
|
26
26
|
"types": "./dist/node.d.ts",
|
|
27
27
|
"import": "./dist/node.js"
|
|
28
|
+
},
|
|
29
|
+
"./event": {
|
|
30
|
+
"types": "./dist/event.d.ts",
|
|
31
|
+
"import": "./dist/event.js"
|
|
32
|
+
},
|
|
33
|
+
"./web": {
|
|
34
|
+
"types": "./dist/web.d.ts",
|
|
35
|
+
"import": "./dist/web.js"
|
|
28
36
|
}
|
|
29
37
|
},
|
|
30
38
|
"dependencies": {
|
|
31
|
-
"@pmate/log": "0.
|
|
39
|
+
"@pmate/log": "0.3.1"
|
|
32
40
|
},
|
|
33
41
|
"peerDependencies": {
|
|
34
42
|
"react": ">=18"
|