@pmate/log-sdk 0.3.0 → 0.5.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 -5
- package/dist/accessTransport.d.ts +21 -0
- package/dist/accessTransport.js +49 -0
- package/dist/blockchainEndpoint.d.ts +4 -0
- package/dist/blockchainEndpoint.js +23 -0
- package/dist/blockchainSink.d.ts +9 -0
- package/dist/blockchainSink.js +32 -0
- package/dist/browser.d.ts +8 -3
- package/dist/browser.js +24 -10
- package/dist/event.d.ts +24 -0
- package/dist/event.js +97 -0
- package/dist/fetchAccess.d.ts +11 -0
- package/dist/fetchAccess.js +91 -0
- package/dist/httpAccess.d.ts +30 -0
- package/dist/httpAccess.js +30 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/logEnvelope.d.ts +20 -0
- package/dist/logEnvelope.js +25 -0
- package/dist/node.d.ts +15 -3
- package/dist/node.js +42 -28
- package/dist/react.d.ts +1 -1
- package/dist/react.js +28 -17
- package/dist/shared.js +3 -1
- package/dist/writer.d.ts +22 -0
- package/dist/writer.js +38 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
3
3
|
## Background
|
|
4
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
5
|
|
|
6
|
+
## Runtime and Event APIs
|
|
7
|
+
|
|
8
|
+
Classification follows event meaning, not the runtime or the presence of HTTP.
|
|
9
|
+
The API exposes separate scopes; there is no top-level `append` or `track`.
|
|
10
|
+
|
|
11
|
+
| API | Meaning | Chain | Retention |
|
|
12
|
+
| --- | --- | --- | --- |
|
|
13
|
+
| `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 |
|
|
14
|
+
| `log.event.pageView()` | Explicit browser page visit, with current pathname and generated page-view ID | `web-logs` | 1 year |
|
|
15
|
+
| `log.event.track(name, properties)` | Explicitly defined user behavior | `web-logs` | 1 year |
|
|
16
|
+
|
|
17
|
+
React exposes both scopes. Node exposes only `runtime`; `runtime.append` rejects `pv`
|
|
18
|
+
and `behavior` topics. Callers cannot set the category or destination chain.
|
|
19
|
+
Retention is enforced by chain configuration, not by the SDK queue.
|
|
20
|
+
Automatic page views use the Event route. Browser errors, console and vitals use
|
|
21
|
+
Runtime even though they originate in the browser. Intake grants must explicitly
|
|
22
|
+
allow each category/topic pair.
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
const log = useLog() // @pmate/log-sdk/react
|
|
26
|
+
log.runtime.append("application", { data: { event: "sync.failed" } })
|
|
27
|
+
log.event.track("signup.clicked", { button: "signup" })
|
|
28
|
+
log.event.pageView() // use when manually recording a page visit
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Public event keys and initialization
|
|
32
|
+
|
|
33
|
+
Create a log intake key at https://dev.pmate.chat/log-keys. Bind a registered service,
|
|
34
|
+
application, environment, platform, allowed topics and quota. Creation is the only
|
|
35
|
+
provisioning step: there is no per-topic init or manual table creation. Instantiate
|
|
36
|
+
the SDK once per application. The key determines service/environment server-side;
|
|
37
|
+
no user login or account token is needed to append events. It cannot read logs.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createLogger } from '@pmate/log-sdk/browser'
|
|
41
|
+
const log = createLogger({
|
|
42
|
+
endpoint: 'https://log-api.pmate.chat',
|
|
43
|
+
eventKey: 'KEY_FROM_DEVELOPERS', appId: 'pmate:hello',
|
|
44
|
+
})
|
|
45
|
+
log.event.track('signup.clicked', { source: 'home' })
|
|
46
|
+
await log.flush()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For native JavaScript clients, use `@pmate/log-sdk/event` with the same options plus
|
|
50
|
+
`platform: 'ios'` or `'android'`. It has no browser/Node dependencies; call
|
|
51
|
+
`log.event.pageView('/screen')` explicitly. Other native languages can POST
|
|
52
|
+
`{category:'event',topic:'behavior',items:[{id,time,data:{appId,platform,event}}]}`
|
|
53
|
+
to `/event/logs/batch` with `x-pmate-log-key`. IDs and timestamps must be retained
|
|
54
|
+
on retries. Profile/access IDs are optional untrusted client claims.
|
|
55
|
+
|
|
56
|
+
Web keys may restrict exact HTTP(S) Origins. Native keys need no Origin. Quotas
|
|
57
|
+
are per active intake process (production uses one process); no distributed quota
|
|
58
|
+
is claimed. Disable/rotation writes are confirmed against the Store index before
|
|
59
|
+
success; new requests then reject the old key. In-flight writes may complete.
|
|
60
|
+
Storage errors fail closed. Plaintext keys are returned once; list responses omit
|
|
61
|
+
digests. The current single management writer serializes mutations; deploying
|
|
62
|
+
multiple management writers requires storage-level concurrency control first.
|
|
63
|
+
|
|
64
|
+
An `eventKey` only sends behavior/PV. Automatic runtime diagnostics require the
|
|
65
|
+
existing scoped `key` plus serviceId/environment; diagnostics are not routed through
|
|
66
|
+
the event key. Both keys can be provided to one browser/React logger.
|
|
67
|
+
|
|
68
|
+
The SDK now exposes only `runtime` / `event`. Update previous `.app` / `.web`
|
|
69
|
+
call sites before upgrading. Existing static grants and old request categories
|
|
70
|
+
are explicitly translated at legacy HTTP ingress during migration; physical
|
|
71
|
+
`app-logs` / `web-logs` chains and historical rows are unchanged.
|
|
72
|
+
|
|
6
73
|
## Identity
|
|
7
74
|
`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
75
|
|
|
@@ -21,7 +88,7 @@ function App({ profileId }) {
|
|
|
21
88
|
}
|
|
22
89
|
function Content() {
|
|
23
90
|
const log = useLog()
|
|
24
|
-
return <button onClick={() => log.append('application', {data:{event:'feature.opened'}})}>Open</button>
|
|
91
|
+
return <button onClick={() => log.runtime.append('application', {data:{event:'feature.opened'}})}>Open</button>
|
|
25
92
|
}
|
|
26
93
|
```
|
|
27
94
|
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.
|
|
@@ -36,20 +103,49 @@ await log.close()
|
|
|
36
103
|
```
|
|
37
104
|
`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
105
|
|
|
106
|
+
## Entry points
|
|
107
|
+
|
|
108
|
+
React applications report through `@pmate/log-sdk/react`. Node services import
|
|
109
|
+
`createLogger` and transport helpers from `@pmate/log-sdk`. The `/node` export
|
|
110
|
+
remains a compatibility alias. `@pmate/log` is the SDK's internal browser transport;
|
|
111
|
+
applications should not initialize a second logger with it.
|
|
112
|
+
|
|
39
113
|
## Node
|
|
40
114
|
```ts
|
|
41
|
-
import { createLogger } from '@pmate/log-sdk
|
|
115
|
+
import { createLogger } from '@pmate/log-sdk'
|
|
42
116
|
const log = createLogger({
|
|
43
117
|
appId: 'pmate:hello', serviceId: 'pmate:hello-api', environment: 'prod',
|
|
44
118
|
endpoint: 'https://log-api.pmate.chat', token: process.env.LOG_INGEST_TOKEN,
|
|
45
119
|
})
|
|
46
120
|
await log.withRequest(request, {profileId: authenticatedProfile?.id}, async scope => {
|
|
47
|
-
log.append('application', {data:{event:'http.request', requestId:scope.requestId}})
|
|
121
|
+
log.runtime.append('application', {data:{event:'http.request', requestId:scope.requestId}})
|
|
48
122
|
// business work; report completion/error without raw bodies
|
|
49
123
|
})
|
|
50
124
|
await log.close()
|
|
51
125
|
```
|
|
52
|
-
Use authenticatedProfile from server authentication, never a client body/header. AsyncLocalStorage isolates concurrent requests and anonymous requests clear inherited identity.
|
|
126
|
+
Use authenticatedProfile from server authentication, never a client body/header. AsyncLocalStorage isolates concurrent requests and anonymous requests clear inherited identity. For private-network writes, use the SDK sink:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { createLogger, createAppLogSink } from "@pmate/log-sdk"
|
|
130
|
+
const log = createLogger({
|
|
131
|
+
appId: "pmate:hello", serviceId: "pmate:hello-api", environment: "prod",
|
|
132
|
+
sink: createAppLogSink({
|
|
133
|
+
blockchainUrl: process.env.BLOCKCHAIN_BASE_URL!, // node01: http://infra01:6801
|
|
134
|
+
authorization: serviceSigner, // service-owned signature
|
|
135
|
+
proxyToken: process.env.PMATE_CHAIN_PROXY_TOKEN, // for HTTPS chain-proxy
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`createAppAccessLogger` and `withFetchAccess` use this same Node logger queue.
|
|
141
|
+
They preserve server request IDs and allowlisted route templates. `createAppLogWriter`
|
|
142
|
+
provides acknowledged `append`/`appendMany` for audit/deployment records; failures
|
|
143
|
+
propagate instead of being silently queued. `createAppAppendSink` adapts an existing
|
|
144
|
+
transactional appender. Do not replace required audit acknowledgement with
|
|
145
|
+
best-effort telemetry. SDK stdout includes the original event ID and
|
|
146
|
+
`logUpload: "application"`, so the collector does not upload it again.
|
|
147
|
+
|
|
148
|
+
Alternatively provide a custom `sink(batch, signal)`; 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
149
|
|
|
54
150
|
## Counting
|
|
55
151
|
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.
|
|
@@ -57,7 +153,7 @@ The envelope `id` is the event deduplication key. PV counts pv events; sessions
|
|
|
57
153
|
## Error and crash diagnostics
|
|
58
154
|
|
|
59
155
|
Caught exceptions need explicit reporting; a global rejection listener cannot see
|
|
60
|
-
an exception already handled by application code. Use `log.captureError(error,
|
|
156
|
+
an exception already handled by application code. Use `log.runtime.captureError(error,
|
|
61
157
|
{ operation: 'chat.send' })` on browser, React and Node loggers. Error records use
|
|
62
158
|
the `error` topic and include bounded message/stack diagnostics with URL queries
|
|
63
159
|
removed. Applications must redact domain-specific sensitive strings before
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type BlockchainSinkOptions } from "./blockchainSink.js";
|
|
2
|
+
import type { AccessRecord } from "./httpAccess.js";
|
|
3
|
+
/** HTTP records share the Node SDK queue, redaction, IDs and acknowledged sink. */
|
|
4
|
+
export declare function createAppAccessLogger(options: BlockchainSinkOptions & {
|
|
5
|
+
service: string;
|
|
6
|
+
env: string;
|
|
7
|
+
chain?: string;
|
|
8
|
+
stdout?: (record: AccessRecord & {
|
|
9
|
+
logUpload: "application";
|
|
10
|
+
id: string;
|
|
11
|
+
}) => void;
|
|
12
|
+
warn?: (event: {
|
|
13
|
+
type: "access-log-dropped" | "access-log-write-failed";
|
|
14
|
+
count: number;
|
|
15
|
+
}) => void;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}): {
|
|
18
|
+
emit(record: AccessRecord): void;
|
|
19
|
+
flush: () => Promise<void>;
|
|
20
|
+
close: () => Promise<void>;
|
|
21
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createLogger } from "./node.js";
|
|
2
|
+
import { createAppLogSink } from "./blockchainSink.js";
|
|
3
|
+
/** HTTP records share the Node SDK queue, redaction, IDs and acknowledged sink. */
|
|
4
|
+
export function createAppAccessLogger(options) {
|
|
5
|
+
if ((options.chain ?? "app-logs") !== "app-logs")
|
|
6
|
+
throw Error("Access logs require app-logs");
|
|
7
|
+
if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(options.env))
|
|
8
|
+
throw Error("Invalid log environment");
|
|
9
|
+
let sink;
|
|
10
|
+
try {
|
|
11
|
+
sink = createAppLogSink(options);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw Error("Invalid access log blockchain endpoint");
|
|
15
|
+
}
|
|
16
|
+
const logger = createLogger({ appId: options.service, serviceId: options.service, environment: options.env,
|
|
17
|
+
sink, stdout: false, maxQueue: 1000, timeoutMs: options.timeoutMs,
|
|
18
|
+
onWarning(event) {
|
|
19
|
+
if (event.code === "log_dropped") {
|
|
20
|
+
try {
|
|
21
|
+
(options.warn ?? console.warn)({ type: "access-log-write-failed", count: event.count });
|
|
22
|
+
}
|
|
23
|
+
catch { /* Best effort. */ }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
emit(record) {
|
|
29
|
+
const { requestId, method, route, status, durationMs, outcome } = record.data;
|
|
30
|
+
const safe = { time: record.time, type: record.type, level: record.level,
|
|
31
|
+
data: { schemaVersion: 1, service: options.service, env: options.env, requestId, method, route,
|
|
32
|
+
...(status !== undefined ? { status } : {}), ...(durationMs !== undefined ? { durationMs } : {}),
|
|
33
|
+
...(outcome ? { outcome } : {}) } };
|
|
34
|
+
const request = new Request("https://logging.invalid", { headers: { "x-request-id": requestId } });
|
|
35
|
+
const entry = logger.withRequest(request, {}, () => logger.runtime.append("application", {
|
|
36
|
+
time: safe.time, content: `${method} ${route}${status === undefined ? "" : ` ${status}`}`,
|
|
37
|
+
data: { ...safe.data, event: safe.type, level: safe.level },
|
|
38
|
+
}));
|
|
39
|
+
if (!entry)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
(options.stdout ?? (value => console.info(JSON.stringify(value))))({ ...safe, id: entry.id, logUpload: "application" });
|
|
43
|
+
}
|
|
44
|
+
catch { /* Best effort. */ }
|
|
45
|
+
void logger.flush();
|
|
46
|
+
},
|
|
47
|
+
flush: logger.flush, close: logger.close,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Server-configured destinations only. Public writers use HTTPS chain-proxy;
|
|
2
|
+
* services on the private network may connect directly to infra01. */
|
|
3
|
+
export declare function blockchainEndpoint(value: string): string;
|
|
4
|
+
export declare function serviceBlockchainUrl(env: Record<string, string | undefined>): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Server-configured destinations only. Public writers use HTTPS chain-proxy;
|
|
2
|
+
* services on the private network may connect directly to infra01. */
|
|
3
|
+
export function blockchainEndpoint(value) {
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
const host = url.hostname;
|
|
6
|
+
const octets = /^\d+\.\d+\.\d+\.\d+$/.test(host) ? host.split(".").map(Number) : [];
|
|
7
|
+
const privateIPv4 = octets.length === 4 && octets.every(n => n >= 0 && n <= 255) &&
|
|
8
|
+
(octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
9
|
+
(octets[0] === 192 && octets[1] === 168));
|
|
10
|
+
const internal = privateIPv4 || ["infra01", "localhost", "127.0.0.1", "[::1]"].includes(host);
|
|
11
|
+
if (url.username || url.password || url.search || url.hash ||
|
|
12
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && internal)))
|
|
13
|
+
throw new Error("Invalid blockchain endpoint: use HTTPS or private-network HTTP without credentials, query or fragment");
|
|
14
|
+
if (host === "indexer.pmate.chat" && /^\/infra\/blockchain(?:\/|$)/.test(url.pathname))
|
|
15
|
+
throw new Error("Retired blockchain endpoint: use private infra01 or HTTPS chain-proxy");
|
|
16
|
+
return url.toString().replace(/\/+$/, "");
|
|
17
|
+
}
|
|
18
|
+
export function serviceBlockchainUrl(env) {
|
|
19
|
+
const value = env.ACCESS_LOG_BLOCKCHAIN_URL ?? env.BLOCKCHAIN_BASE_URL ?? env.BLOCKCHAIN_ENDPOINT;
|
|
20
|
+
if (!value)
|
|
21
|
+
throw new Error("Missing ACCESS_LOG_BLOCKCHAIN_URL or BLOCKCHAIN_BASE_URL");
|
|
22
|
+
return blockchainEndpoint(value);
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LogBatch } from "./node.js";
|
|
2
|
+
export type BlockchainSinkOptions = {
|
|
3
|
+
blockchainUrl: string;
|
|
4
|
+
authorization: (action: "write", chain: string) => string | Promise<string>;
|
|
5
|
+
proxyToken?: string;
|
|
6
|
+
fetcher?: typeof fetch;
|
|
7
|
+
};
|
|
8
|
+
/** Encoding and one acknowledged append. The Node logger owns batching/retries. */
|
|
9
|
+
export declare function createAppLogSink(options: BlockchainSinkOptions): (batch: LogBatch, signal: AbortSignal) => Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { buildLogEnvelope } from "./logEnvelope.js";
|
|
2
|
+
import { blockchainEndpoint } from "./blockchainEndpoint.js";
|
|
3
|
+
/** Encoding and one acknowledged append. The Node logger owns batching/retries. */
|
|
4
|
+
export function createAppLogSink(options) {
|
|
5
|
+
const target = `${blockchainEndpoint(options.blockchainUrl)}/chains/app-logs/logs`;
|
|
6
|
+
return async (batch, signal) => {
|
|
7
|
+
if (batch.category !== "runtime")
|
|
8
|
+
throw Object.assign(Error("App sink rejects web logs"), { retryable: false });
|
|
9
|
+
const logs = batch.items.map(item => {
|
|
10
|
+
const { kind, topic, v, data } = buildLogEnvelope(batch.serviceId, batch.topic, batch.environment, item);
|
|
11
|
+
return { kind, topic, v, data };
|
|
12
|
+
});
|
|
13
|
+
const authorization = await options.authorization("write", "app-logs");
|
|
14
|
+
if (!authorization.startsWith("Bearer ") || authorization.length <= 7)
|
|
15
|
+
throw Object.assign(Error("Log service authorization required"), { retryable: false });
|
|
16
|
+
const response = await (options.fetcher ?? fetch)(target, {
|
|
17
|
+
method: "POST", redirect: "error", signal,
|
|
18
|
+
headers: { Authorization: authorization, "Content-Type": "application/json",
|
|
19
|
+
...(options.proxyToken ? { "x-proxy-token": options.proxyToken } : {}) },
|
|
20
|
+
body: JSON.stringify({ logs }),
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
await response.body?.cancel();
|
|
24
|
+
throw Object.assign(Error("Log append rejected"), {
|
|
25
|
+
retryable: response.status >= 500 || [408, 429].includes(response.status),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const receipt = await response.json();
|
|
29
|
+
if (!receipt.success || receipt.data?.appended !== logs.length || !receipt.data.acceptedAt)
|
|
30
|
+
throw Error("Log append receipt missing");
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -9,11 +9,16 @@ 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
|
+
append(topic: string, input: LogInput | string): void;
|
|
14
|
+
captureError(error: unknown, context?: Parameters<typeof errorInput>[1]): void;
|
|
15
|
+
};
|
|
16
|
+
event: {
|
|
17
|
+
pageView(): void;
|
|
18
|
+
track(name: string, properties?: Record<string, unknown>): void;
|
|
19
|
+
};
|
|
13
20
|
getContext: () => LogContext;
|
|
14
|
-
track(name: string, properties?: Record<string, unknown>): void;
|
|
15
21
|
setProfileId(value?: string | null): void;
|
|
16
|
-
append(topic: string, input: LogInput | string): void;
|
|
17
22
|
/** Explicit request wrapper: never patches global fetch or decorates unlisted origins. */
|
|
18
23
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
19
24
|
flush: () => Promise<void>;
|
package/dist/browser.js
CHANGED
|
@@ -62,14 +62,32 @@ export function createLogger(options) {
|
|
|
62
62
|
throw error;
|
|
63
63
|
}
|
|
64
64
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
runtime: {
|
|
66
|
+
append(topic, input) {
|
|
67
|
+
if (["pv", "behavior"].includes(topic))
|
|
68
|
+
throw Error("PV and behavior require the event API");
|
|
69
|
+
if (!closed) {
|
|
70
|
+
access.touch();
|
|
71
|
+
transport.append(topic, input);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
captureError(error, context) {
|
|
75
|
+
this.append("error", errorInput(error, context));
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
event: {
|
|
79
|
+
pageView() { if (!closed) {
|
|
80
|
+
access.touch();
|
|
81
|
+
transport.pageView();
|
|
82
|
+
} },
|
|
83
|
+
track(name, properties = {}) {
|
|
84
|
+
if (!closed) {
|
|
85
|
+
access.touch();
|
|
86
|
+
transport.track(name, properties);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
67
89
|
},
|
|
68
90
|
getContext: context,
|
|
69
|
-
track(name, properties = {}) { if (!closed) {
|
|
70
|
-
access.touch();
|
|
71
|
-
transport.track(name, properties);
|
|
72
|
-
} },
|
|
73
91
|
setProfileId(value) {
|
|
74
92
|
if (closed)
|
|
75
93
|
return;
|
|
@@ -82,10 +100,6 @@ export function createLogger(options) {
|
|
|
82
100
|
access.touch();
|
|
83
101
|
transport.append("application", { data: { event: next ? "identity.identified" : "identity.cleared" } });
|
|
84
102
|
},
|
|
85
|
-
append(topic, input) { if (!closed) {
|
|
86
|
-
access.touch();
|
|
87
|
-
transport.append(topic, input);
|
|
88
|
-
} },
|
|
89
103
|
/** Explicit request wrapper: never patches global fetch or decorates unlisted origins. */
|
|
90
104
|
fetch(input, init) {
|
|
91
105
|
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { accessRecorder } from "./httpAccess.js";
|
|
2
|
+
export type AccessContext = {
|
|
3
|
+
requestId: string;
|
|
4
|
+
/** Set only from the framework's matched route template, never the raw URL. */
|
|
5
|
+
setRoute(template: string): void;
|
|
6
|
+
};
|
|
7
|
+
/** Wrap the outer Fetch handler, including its error/404 handlers.
|
|
8
|
+
* Body completion means the runtime consumed the stream, not peer acknowledgement.
|
|
9
|
+
* Does not inspect, clone or buffer request/response payloads.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withFetchAccess(handler: (request: Request, context: AccessContext) => Response | Promise<Response>, options: Parameters<typeof accessRecorder>[0]): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { accessRecorder } from "./httpAccess.js";
|
|
2
|
+
/** Wrap the outer Fetch handler, including its error/404 handlers.
|
|
3
|
+
* Body completion means the runtime consumed the stream, not peer acknowledgement.
|
|
4
|
+
* Does not inspect, clone or buffer request/response payloads.
|
|
5
|
+
*/
|
|
6
|
+
export function withFetchAccess(handler, options) {
|
|
7
|
+
const recorder = accessRecorder(options);
|
|
8
|
+
return async (request) => {
|
|
9
|
+
const record = recorder.start(request.method, request.headers.get("x-request-id"));
|
|
10
|
+
let route, status = 499, finished = false;
|
|
11
|
+
let cancelBody;
|
|
12
|
+
const finish = (outcome) => {
|
|
13
|
+
if (finished)
|
|
14
|
+
return;
|
|
15
|
+
finished = true;
|
|
16
|
+
request.signal.removeEventListener("abort", abort);
|
|
17
|
+
record.finish(status, route, outcome);
|
|
18
|
+
};
|
|
19
|
+
const abort = () => { finish("aborted"); cancelBody?.(); };
|
|
20
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
21
|
+
if (request.signal.aborted)
|
|
22
|
+
abort();
|
|
23
|
+
try {
|
|
24
|
+
const response = await handler(request, { requestId: record.requestId, setRoute(value) { route = value; } });
|
|
25
|
+
status = response.status;
|
|
26
|
+
const headers = new Headers(response.headers);
|
|
27
|
+
headers.set("x-request-id", record.requestId);
|
|
28
|
+
if (!response.body) {
|
|
29
|
+
finish("response");
|
|
30
|
+
return new Response(null, { status, statusText: response.statusText, headers });
|
|
31
|
+
}
|
|
32
|
+
const reader = response.body.getReader();
|
|
33
|
+
let ended = false;
|
|
34
|
+
const stop = async (reason) => {
|
|
35
|
+
if (ended)
|
|
36
|
+
return;
|
|
37
|
+
ended = true;
|
|
38
|
+
try {
|
|
39
|
+
await reader.cancel(reason);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const body = new ReadableStream({
|
|
46
|
+
start(controller) {
|
|
47
|
+
cancelBody = () => {
|
|
48
|
+
if (ended)
|
|
49
|
+
return;
|
|
50
|
+
controller.error(new DOMException("Request aborted", "AbortError"));
|
|
51
|
+
void stop().catch(() => { });
|
|
52
|
+
};
|
|
53
|
+
if (request.signal.aborted)
|
|
54
|
+
cancelBody();
|
|
55
|
+
},
|
|
56
|
+
async pull(controller) {
|
|
57
|
+
if (ended)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const result = await reader.read();
|
|
61
|
+
if (ended)
|
|
62
|
+
return;
|
|
63
|
+
if (result.done) {
|
|
64
|
+
ended = true;
|
|
65
|
+
reader.releaseLock();
|
|
66
|
+
controller.close();
|
|
67
|
+
finish("response");
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
controller.enqueue(result.value);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (ended)
|
|
74
|
+
return;
|
|
75
|
+
ended = true;
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
controller.error(error);
|
|
78
|
+
finish("error");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async cancel(reason) { finish("aborted"); await stop(reason); },
|
|
82
|
+
}, { highWaterMark: 0 });
|
|
83
|
+
return new Response(body, { status, statusText: response.statusText, headers });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
status = 500;
|
|
87
|
+
finish("error");
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type AccessRecord = {
|
|
2
|
+
time: number;
|
|
3
|
+
type: "http.request" | "http.access";
|
|
4
|
+
level: "info" | "warn" | "error";
|
|
5
|
+
data: {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
service: string;
|
|
8
|
+
env: string;
|
|
9
|
+
requestId: string;
|
|
10
|
+
method: string;
|
|
11
|
+
route: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
outcome?: "response" | "error" | "aborted";
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/** Only accepts server-configured route templates; never pass request.pathname. */
|
|
18
|
+
export declare function accessRecorder(options: {
|
|
19
|
+
service: string;
|
|
20
|
+
env: string;
|
|
21
|
+
routes: string[];
|
|
22
|
+
emit: (record: AccessRecord) => void;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
monotonic?: () => number;
|
|
25
|
+
}): {
|
|
26
|
+
start(method: string, upstreamRequestId?: string | null): {
|
|
27
|
+
requestId: string;
|
|
28
|
+
finish(status: number, route?: string, outcome?: "response" | "error" | "aborted"): void;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Only accepts server-configured route templates; never pass request.pathname. */
|
|
2
|
+
export function accessRecorder(options) {
|
|
3
|
+
const now = options.now ?? Date.now;
|
|
4
|
+
const monotonic = options.monotonic ?? (() => performance.now());
|
|
5
|
+
const routes = new Set(options.routes);
|
|
6
|
+
const emit = (record) => { try {
|
|
7
|
+
options.emit(record);
|
|
8
|
+
}
|
|
9
|
+
catch { /* Observability cannot fail a request. */ } };
|
|
10
|
+
return {
|
|
11
|
+
start(method, upstreamRequestId) {
|
|
12
|
+
const started = monotonic();
|
|
13
|
+
const data = { schemaVersion: 1, service: options.service, env: options.env,
|
|
14
|
+
requestId: upstreamRequestId && /^(?:[a-fA-F0-9]{32}|[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$/.test(upstreamRequestId) ? upstreamRequestId : crypto.randomUUID(), method: /^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE)$/.test(method) ? method : "OTHER", route: "pending" };
|
|
15
|
+
// Completion records retain request outcome without doubling every request.
|
|
16
|
+
let finished = false;
|
|
17
|
+
return {
|
|
18
|
+
requestId: data.requestId,
|
|
19
|
+
finish(status, route, outcome = "response") {
|
|
20
|
+
if (finished)
|
|
21
|
+
return;
|
|
22
|
+
finished = true;
|
|
23
|
+
const code = Number.isInteger(status) && status >= 100 && status <= 599 ? status : 500;
|
|
24
|
+
emit({ time: now(), type: "http.access", level: code >= 500 || outcome === "error" ? "error" : code >= 400 || outcome === "aborted" ? "warn" : "info",
|
|
25
|
+
data: { ...data, route: route && routes.has(route) ? route : "unmatched", status: code, durationMs: Math.max(0, monotonic() - started), outcome } });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./shared.js";
|
|
2
|
+
export * from "./node.js";
|
|
3
|
+
export * from "./blockchainEndpoint.js";
|
|
4
|
+
export * from "./blockchainSink.js";
|
|
5
|
+
export * from "./httpAccess.js";
|
|
6
|
+
export * from "./fetchAccess.js";
|
|
7
|
+
export * from "./accessTransport.js";
|
|
8
|
+
export * from "./writer.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./shared.js";
|
|
2
|
+
export * from "./node.js";
|
|
3
|
+
export * from "./blockchainEndpoint.js";
|
|
4
|
+
export * from "./blockchainSink.js";
|
|
5
|
+
export * from "./httpAccess.js";
|
|
6
|
+
export * from "./fetchAccess.js";
|
|
7
|
+
export * from "./accessTransport.js";
|
|
8
|
+
export * from "./writer.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type LogEntry } from "./shared.js";
|
|
2
|
+
/** AppLog's version-1 StdTable wire format. This module has no Store runtime
|
|
3
|
+
* dependency, so the published Node SDK also works in standard Node.js. */
|
|
4
|
+
export declare function buildLogEnvelope(serviceId: string, topic: string, environment: string, entry: LogEntry): {
|
|
5
|
+
kind: 10000;
|
|
6
|
+
topic: string;
|
|
7
|
+
v: number;
|
|
8
|
+
data: {
|
|
9
|
+
type: "create";
|
|
10
|
+
after: {
|
|
11
|
+
data?: Record<string, unknown> | undefined;
|
|
12
|
+
content?: string | undefined;
|
|
13
|
+
id: string;
|
|
14
|
+
time: number;
|
|
15
|
+
serviceId: string;
|
|
16
|
+
topic: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { redact } from "./shared.js";
|
|
2
|
+
/** AppLog's version-1 StdTable wire format. This module has no Store runtime
|
|
3
|
+
* dependency, so the published Node SDK also works in standard Node.js. */
|
|
4
|
+
export function buildLogEnvelope(serviceId, topic, environment, entry) {
|
|
5
|
+
const parts = /^([a-z0-9][a-z0-9-]*):([a-z0-9][a-z0-9_-]*)$/.exec(serviceId);
|
|
6
|
+
const name = parts ? `logs_${parts[2]}_${topic}` : "";
|
|
7
|
+
if (!parts || parts[1].length > 30 || name.length > 63 ||
|
|
8
|
+
!/^[a-z][a-z0-9-]{0,31}$/.test(topic) || ["pv", "behavior"].includes(topic))
|
|
9
|
+
throw Object.assign(Error("Invalid application log service/topic"), { retryable: false });
|
|
10
|
+
if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(environment) ||
|
|
11
|
+
!entry.id || entry.id.length > 256 || entry.id.includes("\0") ||
|
|
12
|
+
!Number.isSafeInteger(entry.time) || entry.time < 0)
|
|
13
|
+
throw Object.assign(Error("Invalid log environment or identity"), { retryable: false });
|
|
14
|
+
if ((entry.content !== undefined && typeof entry.content !== "string") ||
|
|
15
|
+
(entry.data !== undefined && (!entry.data || typeof entry.data !== "object" || Array.isArray(entry.data))) ||
|
|
16
|
+
(!entry.content?.trim() && entry.data === undefined))
|
|
17
|
+
throw Object.assign(Error("Invalid log content/data"), { retryable: false });
|
|
18
|
+
const row = { id: entry.id, time: entry.time, serviceId, topic, environment,
|
|
19
|
+
...(entry.content !== undefined ? { content: entry.content } : {}),
|
|
20
|
+
...(entry.data !== undefined ? { data: redact(entry.data) } : {}) };
|
|
21
|
+
const data = { type: "create", after: row };
|
|
22
|
+
if (new TextEncoder().encode(JSON.stringify(data)).length > 64 * 1024)
|
|
23
|
+
throw Object.assign(Error("Log exceeds 64 KiB"), { retryable: false });
|
|
24
|
+
return { kind: 10000, topic: `@${parts[1]}/${name}`, v: 1, data };
|
|
25
|
+
}
|
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;
|
|
@@ -17,19 +17,31 @@ export type NodeLoggerOptions = {
|
|
|
17
17
|
sink?: (batch: LogBatch, signal: AbortSignal) => Promise<void>;
|
|
18
18
|
maxQueue?: number;
|
|
19
19
|
flushIntervalMs?: number;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
stdout?: false | ((entry: LogEntry & {
|
|
22
|
+
serviceId: string;
|
|
23
|
+
environment: string;
|
|
24
|
+
topic: string;
|
|
25
|
+
logUpload: "application";
|
|
26
|
+
}) => void);
|
|
20
27
|
onWarning?: (event: {
|
|
21
28
|
code: "log_dropped" | "log_send_failed";
|
|
22
29
|
count: number;
|
|
23
30
|
}) => void;
|
|
24
31
|
};
|
|
25
32
|
export declare function createLogger(options: NodeLoggerOptions): {
|
|
26
|
-
|
|
33
|
+
runtime: {
|
|
34
|
+
captureError(error: unknown, context?: Parameters<typeof errorInput>[1]): void;
|
|
35
|
+
append(topic: string, input: LogInput | string): {
|
|
36
|
+
id: string;
|
|
37
|
+
time: number;
|
|
38
|
+
} | undefined;
|
|
39
|
+
};
|
|
27
40
|
getContext: () => LogContext;
|
|
28
41
|
/** verifiedProfile comes from server authentication, never request headers/body. */
|
|
29
42
|
withRequest<T>(request: Request, verifiedProfile: {
|
|
30
43
|
profileId?: string | null;
|
|
31
44
|
}, run: (scope: LogContext) => T): T;
|
|
32
|
-
append(topic: string, input: LogInput | string): void;
|
|
33
45
|
flush: () => Promise<void>;
|
|
34
46
|
close(): Promise<void>;
|
|
35
47
|
};
|
package/dist/node.js
CHANGED
|
@@ -4,6 +4,9 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { ACCESS_HEADER, REQUEST_HEADER, redact, validId, validateAppId, withContext } from "./shared.js";
|
|
5
5
|
export function createLogger(options) {
|
|
6
6
|
validateAppId(options.appId);
|
|
7
|
+
const timeoutMs = options.timeoutMs ?? 8000;
|
|
8
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
9
|
+
throw Error("Invalid log timeout");
|
|
7
10
|
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))
|
|
8
11
|
throw Error("Invalid service or environment");
|
|
9
12
|
const endpoint = options.endpoint ? new URL(options.endpoint) : undefined;
|
|
@@ -56,11 +59,11 @@ export function createLogger(options) {
|
|
|
56
59
|
budget -= selected.length;
|
|
57
60
|
const controller = new AbortController();
|
|
58
61
|
let timeout;
|
|
59
|
-
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));
|
|
60
63
|
void writing.then(() => { stalled = false; }, () => { stalled = false; });
|
|
61
64
|
try {
|
|
62
65
|
await Promise.race([writing, new Promise((_, reject) => {
|
|
63
|
-
timeout = setTimeout(() => { stalled = true; controller.abort(); reject(Error("Log sink timed out")); },
|
|
66
|
+
timeout = setTimeout(() => { stalled = true; controller.abort(); reject(Error("Log sink timed out")); }, timeoutMs);
|
|
64
67
|
})]);
|
|
65
68
|
}
|
|
66
69
|
catch (error) {
|
|
@@ -84,8 +87,43 @@ export function createLogger(options) {
|
|
|
84
87
|
const timer = setInterval(() => { void flush(); }, Math.max(1000, options.flushIntervalMs ?? 5000));
|
|
85
88
|
timer.unref();
|
|
86
89
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
runtime: {
|
|
91
|
+
captureError(error, context) {
|
|
92
|
+
this.append("error", errorInput(error, context));
|
|
93
|
+
},
|
|
94
|
+
append(topic, input) {
|
|
95
|
+
if (["pv", "behavior"].includes(topic))
|
|
96
|
+
throw Error("PV and behavior require the event API");
|
|
97
|
+
if (closing)
|
|
98
|
+
return;
|
|
99
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(topic))
|
|
100
|
+
throw Error("Invalid topic");
|
|
101
|
+
try {
|
|
102
|
+
const value = withContext(input, context.getStore() ?? anonymous);
|
|
103
|
+
const entry = { id: randomUUID(), time: value.time ?? Date.now(),
|
|
104
|
+
...(value.content !== undefined ? { content: redact(value.content) } : {}), data: redact(value.data) };
|
|
105
|
+
const bytes = new TextEncoder().encode(JSON.stringify(entry)).length;
|
|
106
|
+
if (!Number.isSafeInteger(entry.time) || entry.time < 0 || bytes > 32000) {
|
|
107
|
+
warn("log_dropped", 1);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (queue.length >= maxQueue) {
|
|
111
|
+
queue.shift();
|
|
112
|
+
warn("log_dropped", 1);
|
|
113
|
+
}
|
|
114
|
+
queue.push({ topic, entry, bytes, attempts: 0 });
|
|
115
|
+
if (options.stdout !== false) {
|
|
116
|
+
try {
|
|
117
|
+
(options.stdout ?? (value => console.info(JSON.stringify(value))))({ ...entry, serviceId: options.serviceId, environment: options.environment, topic, logUpload: "application" });
|
|
118
|
+
}
|
|
119
|
+
catch { /* Logging cannot fail business work. */ }
|
|
120
|
+
}
|
|
121
|
+
return { id: entry.id, time: entry.time };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
warn("log_dropped", 1);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
89
127
|
},
|
|
90
128
|
getContext: () => context.getStore() ?? anonymous,
|
|
91
129
|
/** verifiedProfile comes from server authentication, never request headers/body. */
|
|
@@ -98,30 +136,6 @@ export function createLogger(options) {
|
|
|
98
136
|
requestId: validId(requestId) ? requestId : randomUUID(), identitySource: verifiedProfile.profileId ? "authenticated" : "anonymous" };
|
|
99
137
|
return context.run(scope, () => run(scope));
|
|
100
138
|
},
|
|
101
|
-
append(topic, input) {
|
|
102
|
-
if (closing)
|
|
103
|
-
return;
|
|
104
|
-
if (!/^[a-z][a-z0-9-]{0,31}$/.test(topic))
|
|
105
|
-
throw Error("Invalid topic");
|
|
106
|
-
try {
|
|
107
|
-
const value = withContext(input, context.getStore() ?? anonymous);
|
|
108
|
-
const entry = { id: randomUUID(), time: value.time ?? Date.now(),
|
|
109
|
-
...(value.content !== undefined ? { content: redact(value.content) } : {}), data: redact(value.data) };
|
|
110
|
-
const bytes = new TextEncoder().encode(JSON.stringify(entry)).length;
|
|
111
|
-
if (!Number.isSafeInteger(entry.time) || entry.time < 0 || bytes > 32000) {
|
|
112
|
-
warn("log_dropped", 1);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (queue.length >= maxQueue) {
|
|
116
|
-
queue.shift();
|
|
117
|
-
warn("log_dropped", 1);
|
|
118
|
-
}
|
|
119
|
-
queue.push({ topic, entry, bytes, attempts: 0 });
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
warn("log_dropped", 1);
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
139
|
flush,
|
|
126
140
|
async close() { closing = true; clearInterval(timer); await flush(); if (queue.length && !stalled)
|
|
127
141
|
await flush(); if (queue.length) {
|
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,22 +16,33 @@ export function LogProvider({ options, profileId, children }) {
|
|
|
16
16
|
const buffered = useRef([]);
|
|
17
17
|
identity.current = profileId;
|
|
18
18
|
const value = useMemo(() => ({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
event: {
|
|
20
|
+
track(name, properties) {
|
|
21
|
+
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name))
|
|
22
|
+
throw Error("Invalid behavior event");
|
|
23
|
+
const profile = identity.current;
|
|
24
|
+
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.event.track(name, properties); });
|
|
25
|
+
},
|
|
26
|
+
pageView() {
|
|
27
|
+
const profile = identity.current;
|
|
28
|
+
void ready.promise.then(instance => { instance?.setProfileId(profile); instance?.event.pageView(); });
|
|
29
|
+
},
|
|
23
30
|
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
runtime: {
|
|
32
|
+
captureError(error, context) { value.runtime.append("error", errorInput(error, context)); },
|
|
33
|
+
append(topic, input) {
|
|
34
|
+
if (["pv", "behavior"].includes(topic))
|
|
35
|
+
throw Error("PV and behavior require the event API");
|
|
36
|
+
if (logger.current) {
|
|
37
|
+
logger.current.setProfileId(identity.current);
|
|
38
|
+
logger.current.runtime.append(topic, input);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
if (buffered.current.length >= 200)
|
|
42
|
+
buffered.current.shift();
|
|
43
|
+
buffered.current.push({ topic, input, profileId: identity.current });
|
|
44
|
+
}
|
|
45
|
+
},
|
|
35
46
|
},
|
|
36
47
|
async fetch(input, init) {
|
|
37
48
|
const instance = logger.current ?? await ready.promise;
|
|
@@ -61,7 +72,7 @@ export function LogProvider({ options, profileId, children }) {
|
|
|
61
72
|
ready.resolve(instance);
|
|
62
73
|
for (const item of buffered.current.splice(0)) {
|
|
63
74
|
instance.setProfileId(item.profileId);
|
|
64
|
-
instance.append(item.topic, item.input);
|
|
75
|
+
instance.runtime.append(item.topic, item.input);
|
|
65
76
|
}
|
|
66
77
|
instance.setProfileId(identity.current);
|
|
67
78
|
});
|
|
@@ -92,7 +103,7 @@ export class LogErrorBoundary extends Component {
|
|
|
92
103
|
static getDerivedStateFromError() { return { failed: true }; }
|
|
93
104
|
componentDidCatch(error, info) {
|
|
94
105
|
try {
|
|
95
|
-
this.context?.append("error", errorInput(error, { operation: "react.render", componentStack: info.componentStack ?? undefined, fatal: true }));
|
|
106
|
+
this.context?.runtime.append("error", errorInput(error, { operation: "react.render", componentStack: info.componentStack ?? undefined, fatal: true }));
|
|
96
107
|
void this.context?.flush().catch(() => { });
|
|
97
108
|
}
|
|
98
109
|
catch { /* Reporting must not crash the fallback. */ }
|
package/dist/shared.js
CHANGED
|
@@ -13,8 +13,10 @@ 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
|
+
if (value === undefined)
|
|
19
|
+
return undefined;
|
|
18
20
|
if (typeof value === "string")
|
|
19
21
|
return value.slice(0, 4000).replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]");
|
|
20
22
|
if (value === null || typeof value === "boolean" || typeof value === "number")
|
package/dist/writer.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildLogEnvelope } from "./logEnvelope.js";
|
|
2
|
+
import { type LogInput } from "./shared.js";
|
|
3
|
+
import type { LogBatch, NodeLoggerOptions } from "./node.js";
|
|
4
|
+
export type LogWriteInput = LogInput & {
|
|
5
|
+
id?: string;
|
|
6
|
+
};
|
|
7
|
+
export type LogSink = (batch: LogBatch, signal: AbortSignal) => Promise<void>;
|
|
8
|
+
/** Adapt an existing transactional chain appender without changing its commit semantics. */
|
|
9
|
+
export declare function createAppAppendSink(appendLogs: (logs: ReturnType<typeof buildLogEnvelope>[]) => Promise<void>): LogSink;
|
|
10
|
+
/** Explicit acknowledgement for audit/deployment writes. Errors propagate to
|
|
11
|
+
* the caller; ordinary best-effort telemetry should use createLogger instead. */
|
|
12
|
+
export declare function createAppLogWriter(options: {
|
|
13
|
+
serviceId: string;
|
|
14
|
+
environment: string;
|
|
15
|
+
topic: string;
|
|
16
|
+
sink: LogSink;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
stdout?: NodeLoggerOptions["stdout"];
|
|
19
|
+
}): {
|
|
20
|
+
appendMany: (inputs: (LogWriteInput | string)[]) => Promise<void>;
|
|
21
|
+
append: (input: LogWriteInput | string) => Promise<void>;
|
|
22
|
+
};
|
package/dist/writer.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { buildLogEnvelope } from "./logEnvelope.js";
|
|
3
|
+
import { redact } from "./shared.js";
|
|
4
|
+
/** Adapt an existing transactional chain appender without changing its commit semantics. */
|
|
5
|
+
export function createAppAppendSink(appendLogs) {
|
|
6
|
+
return async (batch) => {
|
|
7
|
+
if (batch.category !== "runtime")
|
|
8
|
+
throw Object.assign(Error("App sink rejects web logs"), { retryable: false });
|
|
9
|
+
await appendLogs(batch.items.map(entry => buildLogEnvelope(batch.serviceId, batch.topic, batch.environment, entry)));
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Explicit acknowledgement for audit/deployment writes. Errors propagate to
|
|
13
|
+
* the caller; ordinary best-effort telemetry should use createLogger instead. */
|
|
14
|
+
export function createAppLogWriter(options) {
|
|
15
|
+
const appendMany = async (inputs) => {
|
|
16
|
+
if (!inputs.length)
|
|
17
|
+
return;
|
|
18
|
+
const entries = inputs.map(input => {
|
|
19
|
+
const value = typeof input === "string" ? { content: input } : input;
|
|
20
|
+
return { id: value.id ?? randomUUID(), time: value.time ?? Date.now(),
|
|
21
|
+
...(value.content !== undefined ? { content: redact(value.content) } : {}),
|
|
22
|
+
...(value.data !== undefined ? { data: redact(value.data) } : {}) };
|
|
23
|
+
});
|
|
24
|
+
// Validate before invoking a caller's transactional appender.
|
|
25
|
+
for (const entry of entries)
|
|
26
|
+
buildLogEnvelope(options.serviceId, options.topic, options.environment, entry);
|
|
27
|
+
await options.sink({ category: "runtime", serviceId: options.serviceId, environment: options.environment, topic: options.topic, items: entries }, AbortSignal.timeout(options.timeoutMs ?? 8000));
|
|
28
|
+
if (options.stdout !== false)
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
try {
|
|
31
|
+
(options.stdout ?? (row => console.info(JSON.stringify(row))))({ ...entry, serviceId: options.serviceId,
|
|
32
|
+
environment: options.environment, topic: options.topic, logUpload: "application" });
|
|
33
|
+
}
|
|
34
|
+
catch { /* Best effort stdout. */ }
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return { appendMany, append: (input) => appendMany([input]) };
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/log-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "PMate browser, React and Node logs with access and profile context",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
],
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
-
"types": "./dist/
|
|
14
|
-
"
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"node": "./dist/index.js",
|
|
15
|
+
"default": "./dist/shared.js"
|
|
15
16
|
},
|
|
16
17
|
"./browser": {
|
|
17
18
|
"types": "./dist/browser.d.ts",
|
|
@@ -24,10 +25,14 @@
|
|
|
24
25
|
"./node": {
|
|
25
26
|
"types": "./dist/node.d.ts",
|
|
26
27
|
"import": "./dist/node.js"
|
|
28
|
+
},
|
|
29
|
+
"./event": {
|
|
30
|
+
"types": "./dist/event.d.ts",
|
|
31
|
+
"import": "./dist/event.js"
|
|
27
32
|
}
|
|
28
33
|
},
|
|
29
34
|
"dependencies": {
|
|
30
|
-
"@pmate/log": "0.
|
|
35
|
+
"@pmate/log": "0.3.0"
|
|
31
36
|
},
|
|
32
37
|
"peerDependencies": {
|
|
33
38
|
"react": ">=18"
|