logtap-sdk 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 +55 -0
- package/index.d.ts +108 -0
- package/index.js +526 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# logtap JS SDK(Browser / Node)
|
|
2
|
+
|
|
3
|
+
用于向 logtap 网关上报「结构化日志」与「埋点事件」。
|
|
4
|
+
|
|
5
|
+
## 配置
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { LogtapClient } from "logtap-sdk";
|
|
9
|
+
|
|
10
|
+
const client = new LogtapClient({
|
|
11
|
+
baseUrl: "http://localhost:8080",
|
|
12
|
+
projectId: 1,
|
|
13
|
+
projectKey: "pk_xxx", // 启用 AUTH_SECRET 时必填
|
|
14
|
+
gzip: true, // 浏览器需要支持 CompressionStream;否则会自动降级为非 gzip
|
|
15
|
+
globalContexts: { app: { version: "1.2.3" } },
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 日志
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
client.info("hello", { k: "v" });
|
|
23
|
+
client.error("boom", { err: String(err), stack: err?.stack });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 埋点
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
client.identify("u1", { plan: "pro" });
|
|
30
|
+
client.track("signup", { from: "landing" });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
也支持在单次调用里带 `contexts/extra/user/deviceId`(覆盖全局默认值):
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
client.info("hello", { k: "v" }, { contexts: { page: { path: location.pathname } } });
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
事件上报会调用 `POST /api/:projectId/track/`,服务端会以 `logs.level=event` 落库并用于事件分析/漏斗。
|
|
40
|
+
|
|
41
|
+
## 自动捕获(可选)
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
client.captureBrowserErrors(); // 浏览器:window.error / unhandledrejection
|
|
45
|
+
client.captureNodeErrors(); // Node:unhandledRejection / uncaughtException
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Flush / 退出前发送
|
|
49
|
+
|
|
50
|
+
SDK 默认每 2 秒自动 flush;也可手动调用:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
await client.flush();
|
|
54
|
+
await client.close();
|
|
55
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type LogtapLevel = "debug" | "info" | "warn" | "error" | "fatal" | "event";
|
|
2
|
+
|
|
3
|
+
export interface LogtapUser {
|
|
4
|
+
id?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
traits?: Record<string, any>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LogtapLog {
|
|
11
|
+
level: LogtapLevel;
|
|
12
|
+
message: string;
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
device_id?: string;
|
|
15
|
+
trace_id?: string;
|
|
16
|
+
span_id?: string;
|
|
17
|
+
fields?: Record<string, any>;
|
|
18
|
+
tags?: Record<string, string>;
|
|
19
|
+
user?: Record<string, any>;
|
|
20
|
+
contexts?: Record<string, any>;
|
|
21
|
+
extra?: Record<string, any>;
|
|
22
|
+
sdk?: Record<string, any>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LogtapTrackEvent {
|
|
26
|
+
name: string;
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
device_id?: string;
|
|
29
|
+
trace_id?: string;
|
|
30
|
+
span_id?: string;
|
|
31
|
+
properties?: Record<string, any>;
|
|
32
|
+
tags?: Record<string, string>;
|
|
33
|
+
user?: Record<string, any>;
|
|
34
|
+
contexts?: Record<string, any>;
|
|
35
|
+
extra?: Record<string, any>;
|
|
36
|
+
sdk?: Record<string, any>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LogtapClientOptions {
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
projectId: number | string;
|
|
42
|
+
projectKey?: string;
|
|
43
|
+
flushIntervalMs?: number;
|
|
44
|
+
maxBatchSize?: number;
|
|
45
|
+
maxQueueSize?: number;
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
gzip?: boolean;
|
|
48
|
+
persistDeviceId?: boolean;
|
|
49
|
+
deviceId?: string;
|
|
50
|
+
user?: LogtapUser;
|
|
51
|
+
globalFields?: Record<string, any>;
|
|
52
|
+
globalProperties?: Record<string, any>;
|
|
53
|
+
globalTags?: Record<string, string>;
|
|
54
|
+
globalContexts?: Record<string, any>;
|
|
55
|
+
beforeSend?: (payload: LogtapLog | LogtapTrackEvent) => LogtapLog | LogtapTrackEvent | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class LogtapClient {
|
|
59
|
+
constructor(options: LogtapClientOptions);
|
|
60
|
+
|
|
61
|
+
setUser(user?: LogtapUser | null): void;
|
|
62
|
+
identify(userId: string, traits?: Record<string, any>): void;
|
|
63
|
+
clearUser(): void;
|
|
64
|
+
setDeviceId(deviceId: string, options?: { persist?: boolean }): void;
|
|
65
|
+
|
|
66
|
+
log(
|
|
67
|
+
level: LogtapLevel,
|
|
68
|
+
message: string,
|
|
69
|
+
fields?: Record<string, any>,
|
|
70
|
+
options?: {
|
|
71
|
+
traceId?: string;
|
|
72
|
+
spanId?: string;
|
|
73
|
+
timestamp?: string | Date;
|
|
74
|
+
tags?: Record<string, string>;
|
|
75
|
+
deviceId?: string;
|
|
76
|
+
user?: LogtapUser;
|
|
77
|
+
contexts?: Record<string, any>;
|
|
78
|
+
extra?: Record<string, any>;
|
|
79
|
+
},
|
|
80
|
+
): void;
|
|
81
|
+
|
|
82
|
+
debug(message: string, fields?: Record<string, any>, options?: { traceId?: string; spanId?: string; timestamp?: string | Date; tags?: Record<string, string>; deviceId?: string; user?: LogtapUser; contexts?: Record<string, any>; extra?: Record<string, any> }): void;
|
|
83
|
+
info(message: string, fields?: Record<string, any>, options?: { traceId?: string; spanId?: string; timestamp?: string | Date; tags?: Record<string, string>; deviceId?: string; user?: LogtapUser; contexts?: Record<string, any>; extra?: Record<string, any> }): void;
|
|
84
|
+
warn(message: string, fields?: Record<string, any>, options?: { traceId?: string; spanId?: string; timestamp?: string | Date; tags?: Record<string, string>; deviceId?: string; user?: LogtapUser; contexts?: Record<string, any>; extra?: Record<string, any> }): void;
|
|
85
|
+
error(message: string, fields?: Record<string, any>, options?: { traceId?: string; spanId?: string; timestamp?: string | Date; tags?: Record<string, string>; deviceId?: string; user?: LogtapUser; contexts?: Record<string, any>; extra?: Record<string, any> }): void;
|
|
86
|
+
fatal(message: string, fields?: Record<string, any>, options?: { traceId?: string; spanId?: string; timestamp?: string | Date; tags?: Record<string, string>; deviceId?: string; user?: LogtapUser; contexts?: Record<string, any>; extra?: Record<string, any> }): void;
|
|
87
|
+
|
|
88
|
+
track(
|
|
89
|
+
name: string,
|
|
90
|
+
properties?: Record<string, any>,
|
|
91
|
+
options?: {
|
|
92
|
+
traceId?: string;
|
|
93
|
+
spanId?: string;
|
|
94
|
+
timestamp?: string | Date;
|
|
95
|
+
tags?: Record<string, string>;
|
|
96
|
+
deviceId?: string;
|
|
97
|
+
user?: LogtapUser;
|
|
98
|
+
contexts?: Record<string, any>;
|
|
99
|
+
extra?: Record<string, any>;
|
|
100
|
+
},
|
|
101
|
+
): void;
|
|
102
|
+
|
|
103
|
+
flush(): Promise<void>;
|
|
104
|
+
close(): Promise<void>;
|
|
105
|
+
|
|
106
|
+
captureBrowserErrors(options?: { includeSource?: boolean }): void;
|
|
107
|
+
captureNodeErrors(): void;
|
|
108
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
const SDK_NAME = "logtap-sdk";
|
|
2
|
+
const SDK_VERSION = "0.1.0";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {"debug"|"info"|"warn"|"error"|"fatal"|"event"} LogtapLevel
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} LogtapUser
|
|
10
|
+
* @property {string=} id
|
|
11
|
+
* @property {string=} email
|
|
12
|
+
* @property {string=} username
|
|
13
|
+
* @property {Record<string, any>=} traits
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} LogtapLog
|
|
18
|
+
* @property {LogtapLevel} level
|
|
19
|
+
* @property {string} message
|
|
20
|
+
* @property {string=} timestamp RFC3339
|
|
21
|
+
* @property {string=} device_id
|
|
22
|
+
* @property {string=} trace_id
|
|
23
|
+
* @property {string=} span_id
|
|
24
|
+
* @property {Record<string, any>=} fields
|
|
25
|
+
* @property {Record<string, string>=} tags
|
|
26
|
+
* @property {Record<string, any>=} user
|
|
27
|
+
* @property {Record<string, any>=} contexts
|
|
28
|
+
* @property {Record<string, any>=} extra
|
|
29
|
+
* @property {Record<string, any>=} sdk
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} LogtapTrackEvent
|
|
34
|
+
* @property {string} name
|
|
35
|
+
* @property {string=} timestamp RFC3339
|
|
36
|
+
* @property {string=} device_id
|
|
37
|
+
* @property {string=} trace_id
|
|
38
|
+
* @property {string=} span_id
|
|
39
|
+
* @property {Record<string, any>=} properties
|
|
40
|
+
* @property {Record<string, string>=} tags
|
|
41
|
+
* @property {Record<string, any>=} user
|
|
42
|
+
* @property {Record<string, any>=} contexts
|
|
43
|
+
* @property {Record<string, any>=} extra
|
|
44
|
+
* @property {Record<string, any>=} sdk
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} LogtapClientOptions
|
|
49
|
+
* @property {string} baseUrl e.g. "http://localhost:8080"
|
|
50
|
+
* @property {number|string} projectId
|
|
51
|
+
* @property {string=} projectKey sent as X-Project-Key: pk_...
|
|
52
|
+
* @property {number=} flushIntervalMs default 2000
|
|
53
|
+
* @property {number=} maxBatchSize default 50
|
|
54
|
+
* @property {number=} maxQueueSize default 1000 per queue
|
|
55
|
+
* @property {number=} timeoutMs default 5000
|
|
56
|
+
* @property {boolean=} gzip default false (browser requires CompressionStream)
|
|
57
|
+
* @property {boolean=} persistDeviceId default true (browser only)
|
|
58
|
+
* @property {string=} deviceId
|
|
59
|
+
* @property {LogtapUser=} user
|
|
60
|
+
* @property {Record<string, any>=} globalFields merged into every log fields
|
|
61
|
+
* @property {Record<string, any>=} globalProperties merged into every track properties
|
|
62
|
+
* @property {Record<string, string>=} globalTags merged into every payload tags
|
|
63
|
+
* @property {Record<string, any>=} globalContexts merged into every payload contexts
|
|
64
|
+
* @property {(payload: LogtapLog|LogtapTrackEvent) => (LogtapLog|LogtapTrackEvent|null)=} beforeSend
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
function isBrowser() {
|
|
68
|
+
return typeof window !== "undefined" && typeof window.document !== "undefined";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nowISO() {
|
|
72
|
+
return new Date().toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBaseUrl(baseUrl) {
|
|
76
|
+
const s = String(baseUrl || "").trim();
|
|
77
|
+
if (!s) throw new Error("baseUrl required");
|
|
78
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeJson(value) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(JSON.stringify(value));
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function randomHex(bytes) {
|
|
94
|
+
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
95
|
+
const buf = new Uint8Array(bytes);
|
|
96
|
+
globalThis.crypto.getRandomValues(buf);
|
|
97
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
98
|
+
}
|
|
99
|
+
return Array.from({ length: bytes }, () => Math.floor(Math.random() * 256))
|
|
100
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
101
|
+
.join("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function newDeviceId() {
|
|
105
|
+
return `d_${randomHex(16)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergeObj(a, b) {
|
|
109
|
+
if (!a && !b) return undefined;
|
|
110
|
+
/** @type {Record<string, any>} */
|
|
111
|
+
const out = {};
|
|
112
|
+
if (a && typeof a === "object") Object.assign(out, a);
|
|
113
|
+
if (b && typeof b === "object") Object.assign(out, b);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mergeTags(a, b) {
|
|
118
|
+
if (!a && !b) return undefined;
|
|
119
|
+
/** @type {Record<string, string>} */
|
|
120
|
+
const out = {};
|
|
121
|
+
if (a && typeof a === "object") Object.assign(out, a);
|
|
122
|
+
if (b && typeof b === "object") Object.assign(out, b);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function gzipIfEnabled(bodyString, enabled) {
|
|
127
|
+
if (!enabled) return { body: bodyString, contentEncoding: undefined };
|
|
128
|
+
|
|
129
|
+
if (isBrowser()) {
|
|
130
|
+
if (typeof CompressionStream !== "function") {
|
|
131
|
+
return { body: bodyString, contentEncoding: undefined };
|
|
132
|
+
}
|
|
133
|
+
const stream = new CompressionStream("gzip");
|
|
134
|
+
const writer = stream.writable.getWriter();
|
|
135
|
+
const enc = new TextEncoder();
|
|
136
|
+
await writer.write(enc.encode(bodyString));
|
|
137
|
+
await writer.close();
|
|
138
|
+
const buf = await new Response(stream.readable).arrayBuffer();
|
|
139
|
+
return { body: new Uint8Array(buf), contentEncoding: "gzip" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { gzipSync } = await import("node:zlib");
|
|
143
|
+
const { Buffer } = await import("node:buffer");
|
|
144
|
+
const gz = gzipSync(Buffer.from(bodyString, "utf8"));
|
|
145
|
+
return { body: gz, contentEncoding: "gzip" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Browser/Node client for logtap logs + tracking.
|
|
150
|
+
*/
|
|
151
|
+
export class LogtapClient {
|
|
152
|
+
/**
|
|
153
|
+
* @param {LogtapClientOptions} options
|
|
154
|
+
*/
|
|
155
|
+
constructor(options) {
|
|
156
|
+
this._baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
157
|
+
this._projectId = String(options.projectId);
|
|
158
|
+
if (!this._projectId) throw new Error("projectId required");
|
|
159
|
+
this._projectKey = options.projectKey ? String(options.projectKey).trim() : "";
|
|
160
|
+
this._timeoutMs = Number(options.timeoutMs ?? 5000);
|
|
161
|
+
this._flushIntervalMs = Number(options.flushIntervalMs ?? 2000);
|
|
162
|
+
this._maxBatchSize = Number(options.maxBatchSize ?? 50);
|
|
163
|
+
this._maxQueueSize = Number(options.maxQueueSize ?? 1000);
|
|
164
|
+
this._gzip = Boolean(options.gzip ?? false);
|
|
165
|
+
this._persistDeviceId = Boolean(options.persistDeviceId ?? true);
|
|
166
|
+
this._beforeSend = typeof options.beforeSend === "function" ? options.beforeSend : null;
|
|
167
|
+
|
|
168
|
+
this._globalFields = options.globalFields && typeof options.globalFields === "object" ? options.globalFields : undefined;
|
|
169
|
+
this._globalProperties =
|
|
170
|
+
options.globalProperties && typeof options.globalProperties === "object" ? options.globalProperties : undefined;
|
|
171
|
+
this._globalTags = options.globalTags && typeof options.globalTags === "object" ? options.globalTags : undefined;
|
|
172
|
+
this._globalContexts = options.globalContexts && typeof options.globalContexts === "object" ? options.globalContexts : undefined;
|
|
173
|
+
|
|
174
|
+
this._user = options.user ? safeJson(options.user) : undefined;
|
|
175
|
+
this._deviceId = options.deviceId ? String(options.deviceId) : this._loadOrCreateDeviceId();
|
|
176
|
+
|
|
177
|
+
/** @type {LogtapLog[]} */
|
|
178
|
+
this._logQueue = [];
|
|
179
|
+
/** @type {LogtapTrackEvent[]} */
|
|
180
|
+
this._trackQueue = [];
|
|
181
|
+
|
|
182
|
+
this._backoffMs = 0;
|
|
183
|
+
this._flushing = null;
|
|
184
|
+
this._timer = null;
|
|
185
|
+
|
|
186
|
+
if (this._flushIntervalMs > 0) {
|
|
187
|
+
this._timer = setInterval(() => void this.flush(), this._flushIntervalMs);
|
|
188
|
+
// In Node, don't keep the process alive for the timer.
|
|
189
|
+
if (!isBrowser() && typeof this._timer?.unref === "function") {
|
|
190
|
+
this._timer.unref();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Set/overwrite current user context (merged into every payload).
|
|
197
|
+
* @param {LogtapUser|null|undefined} user
|
|
198
|
+
*/
|
|
199
|
+
setUser(user) {
|
|
200
|
+
this._user = user ? safeJson(user) : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Convenience identity method.
|
|
205
|
+
* @param {string} userId
|
|
206
|
+
* @param {Record<string, any>=} traits
|
|
207
|
+
*/
|
|
208
|
+
identify(userId, traits) {
|
|
209
|
+
const id = String(userId || "").trim();
|
|
210
|
+
if (!id) return;
|
|
211
|
+
/** @type {LogtapUser} */
|
|
212
|
+
const u = { id };
|
|
213
|
+
if (traits && typeof traits === "object") u.traits = safeJson(traits);
|
|
214
|
+
this.setUser(u);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
clearUser() {
|
|
218
|
+
this._user = undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Override device_id (used for DAU/MAU distinct).
|
|
223
|
+
* @param {string} deviceId
|
|
224
|
+
* @param {{persist?: boolean}=} options
|
|
225
|
+
*/
|
|
226
|
+
setDeviceId(deviceId, options) {
|
|
227
|
+
const id = String(deviceId || "").trim();
|
|
228
|
+
if (!id) return;
|
|
229
|
+
this._deviceId = id;
|
|
230
|
+
const persist = options?.persist ?? this._persistDeviceId;
|
|
231
|
+
if (persist && isBrowser()) {
|
|
232
|
+
try {
|
|
233
|
+
localStorage.setItem("logtap_device_id", id);
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Enqueue a structured log (sent to POST /api/:projectId/logs/).
|
|
240
|
+
* @param {LogtapLevel} level
|
|
241
|
+
* @param {string} message
|
|
242
|
+
* @param {Record<string, any>=} fields
|
|
243
|
+
* @param {{traceId?: string, spanId?: string, timestamp?: string|Date, tags?: Record<string,string>, deviceId?: string, user?: LogtapUser, contexts?: Record<string, any>, extra?: Record<string, any>}=} options
|
|
244
|
+
*/
|
|
245
|
+
log(level, message, fields, options) {
|
|
246
|
+
const msg = String(message || "").trim();
|
|
247
|
+
if (!msg) return;
|
|
248
|
+
|
|
249
|
+
/** @type {LogtapLog} */
|
|
250
|
+
const payload = {
|
|
251
|
+
level: level || "info",
|
|
252
|
+
message: msg,
|
|
253
|
+
device_id: options?.deviceId ? String(options.deviceId) : this._deviceId,
|
|
254
|
+
trace_id: options?.traceId ? String(options.traceId) : undefined,
|
|
255
|
+
span_id: options?.spanId ? String(options.spanId) : undefined,
|
|
256
|
+
timestamp: toTimestamp(options?.timestamp) ?? nowISO(),
|
|
257
|
+
fields: mergeObj(this._globalFields, safeJson(fields)),
|
|
258
|
+
tags: mergeTags(this._globalTags, options?.tags),
|
|
259
|
+
user: options?.user ? safeJson(options.user) : this._user,
|
|
260
|
+
contexts: mergeObj(this._globalContexts, safeJson(options?.contexts)),
|
|
261
|
+
extra: safeJson(options?.extra),
|
|
262
|
+
sdk: { name: SDK_NAME, version: SDK_VERSION, runtime: isBrowser() ? "browser" : "node" },
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this._enqueueLog(payload);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
debug(message, fields, options) {
|
|
269
|
+
this.log("debug", message, fields, options);
|
|
270
|
+
}
|
|
271
|
+
info(message, fields, options) {
|
|
272
|
+
this.log("info", message, fields, options);
|
|
273
|
+
}
|
|
274
|
+
warn(message, fields, options) {
|
|
275
|
+
this.log("warn", message, fields, options);
|
|
276
|
+
}
|
|
277
|
+
error(message, fields, options) {
|
|
278
|
+
this.log("error", message, fields, options);
|
|
279
|
+
}
|
|
280
|
+
fatal(message, fields, options) {
|
|
281
|
+
this.log("fatal", message, fields, options);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Track an event (sent to POST /api/:projectId/track/).
|
|
286
|
+
* @param {string} name
|
|
287
|
+
* @param {Record<string, any>=} properties
|
|
288
|
+
* @param {{traceId?: string, spanId?: string, timestamp?: string|Date, tags?: Record<string,string>, deviceId?: string, user?: LogtapUser, contexts?: Record<string, any>, extra?: Record<string, any>}=} options
|
|
289
|
+
*/
|
|
290
|
+
track(name, properties, options) {
|
|
291
|
+
const n = String(name || "").trim();
|
|
292
|
+
if (!n) return;
|
|
293
|
+
|
|
294
|
+
/** @type {LogtapTrackEvent} */
|
|
295
|
+
const payload = {
|
|
296
|
+
name: n,
|
|
297
|
+
device_id: options?.deviceId ? String(options.deviceId) : this._deviceId,
|
|
298
|
+
trace_id: options?.traceId ? String(options.traceId) : undefined,
|
|
299
|
+
span_id: options?.spanId ? String(options.spanId) : undefined,
|
|
300
|
+
timestamp: toTimestamp(options?.timestamp) ?? nowISO(),
|
|
301
|
+
properties: mergeObj(this._globalProperties, safeJson(properties)),
|
|
302
|
+
tags: mergeTags(this._globalTags, options?.tags),
|
|
303
|
+
user: options?.user ? safeJson(options.user) : this._user,
|
|
304
|
+
contexts: mergeObj(this._globalContexts, safeJson(options?.contexts)),
|
|
305
|
+
extra: safeJson(options?.extra),
|
|
306
|
+
sdk: { name: SDK_NAME, version: SDK_VERSION, runtime: isBrowser() ? "browser" : "node" },
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
this._enqueueTrack(payload);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Flush queued logs + events now.
|
|
314
|
+
* @returns {Promise<void>}
|
|
315
|
+
*/
|
|
316
|
+
async flush() {
|
|
317
|
+
if (this._flushing) return this._flushing;
|
|
318
|
+
this._flushing = this._flushInner().finally(() => {
|
|
319
|
+
this._flushing = null;
|
|
320
|
+
});
|
|
321
|
+
return this._flushing;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Stop periodic flushing and try to send remaining payloads.
|
|
326
|
+
* @returns {Promise<void>}
|
|
327
|
+
*/
|
|
328
|
+
async close() {
|
|
329
|
+
if (this._timer) clearInterval(this._timer);
|
|
330
|
+
this._timer = null;
|
|
331
|
+
await this.flush();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Browser-only: capture window.onerror + unhandledrejection as error logs.
|
|
336
|
+
* @param {{includeSource?: boolean}=} options
|
|
337
|
+
*/
|
|
338
|
+
captureBrowserErrors(options) {
|
|
339
|
+
if (!isBrowser()) return;
|
|
340
|
+
const includeSource = options?.includeSource ?? true;
|
|
341
|
+
|
|
342
|
+
window.addEventListener("error", (ev) => {
|
|
343
|
+
try {
|
|
344
|
+
const msg = ev.error?.message || ev.message || "window.error";
|
|
345
|
+
/** @type {Record<string, any>} */
|
|
346
|
+
const f = {
|
|
347
|
+
kind: "window.error",
|
|
348
|
+
stack: ev.error?.stack,
|
|
349
|
+
filename: includeSource ? ev.filename : undefined,
|
|
350
|
+
lineno: includeSource ? ev.lineno : undefined,
|
|
351
|
+
colno: includeSource ? ev.colno : undefined,
|
|
352
|
+
};
|
|
353
|
+
this.error(msg, f);
|
|
354
|
+
} catch {}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
window.addEventListener("unhandledrejection", (ev) => {
|
|
358
|
+
try {
|
|
359
|
+
const reason = ev.reason;
|
|
360
|
+
const msg = reason?.message || String(reason || "unhandledrejection");
|
|
361
|
+
/** @type {Record<string, any>} */
|
|
362
|
+
const f = {
|
|
363
|
+
kind: "unhandledrejection",
|
|
364
|
+
reason: safeJson(reason) ?? String(reason),
|
|
365
|
+
stack: reason?.stack,
|
|
366
|
+
};
|
|
367
|
+
this.error(msg, f);
|
|
368
|
+
} catch {}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Node-only: capture process unhandledRejection + uncaughtException as error logs.
|
|
374
|
+
*/
|
|
375
|
+
captureNodeErrors() {
|
|
376
|
+
if (isBrowser()) return;
|
|
377
|
+
const p = globalThis.process;
|
|
378
|
+
if (!p?.on) return;
|
|
379
|
+
|
|
380
|
+
p.on("unhandledRejection", (reason) => {
|
|
381
|
+
try {
|
|
382
|
+
const msg = reason?.message || String(reason || "unhandledRejection");
|
|
383
|
+
this.error(msg, { kind: "unhandledRejection", reason: safeJson(reason) ?? String(reason), stack: reason?.stack });
|
|
384
|
+
} catch {}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
p.on("uncaughtException", (err) => {
|
|
388
|
+
try {
|
|
389
|
+
const msg = err?.message || String(err || "uncaughtException");
|
|
390
|
+
this.fatal(msg, { kind: "uncaughtException", stack: err?.stack });
|
|
391
|
+
} catch {}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
_loadOrCreateDeviceId() {
|
|
396
|
+
if (isBrowser() && this._persistDeviceId) {
|
|
397
|
+
try {
|
|
398
|
+
const existing = localStorage.getItem("logtap_device_id");
|
|
399
|
+
if (existing && existing.trim()) return existing.trim();
|
|
400
|
+
} catch {}
|
|
401
|
+
const id = newDeviceId();
|
|
402
|
+
try {
|
|
403
|
+
localStorage.setItem("logtap_device_id", id);
|
|
404
|
+
} catch {}
|
|
405
|
+
return id;
|
|
406
|
+
}
|
|
407
|
+
return newDeviceId();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** @param {LogtapLog} payload */
|
|
411
|
+
_enqueueLog(payload) {
|
|
412
|
+
const p = this._applyBeforeSend(payload);
|
|
413
|
+
if (!p) return;
|
|
414
|
+
this._logQueue.push(p);
|
|
415
|
+
if (this._logQueue.length > this._maxQueueSize) {
|
|
416
|
+
this._logQueue.splice(0, this._logQueue.length - this._maxQueueSize);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** @param {LogtapTrackEvent} payload */
|
|
421
|
+
_enqueueTrack(payload) {
|
|
422
|
+
const p = this._applyBeforeSend(payload);
|
|
423
|
+
if (!p) return;
|
|
424
|
+
this._trackQueue.push(p);
|
|
425
|
+
if (this._trackQueue.length > this._maxQueueSize) {
|
|
426
|
+
this._trackQueue.splice(0, this._trackQueue.length - this._maxQueueSize);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
_applyBeforeSend(payload) {
|
|
431
|
+
if (!this._beforeSend) return payload;
|
|
432
|
+
try {
|
|
433
|
+
return this._beforeSend(payload);
|
|
434
|
+
} catch {
|
|
435
|
+
return payload;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async _flushInner() {
|
|
440
|
+
if (this._backoffMs > 0) {
|
|
441
|
+
await sleep(this._backoffMs);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let sentAny = false;
|
|
445
|
+
let failed = false;
|
|
446
|
+
|
|
447
|
+
while (this._logQueue.length > 0) {
|
|
448
|
+
const ok = await this._flushQueue("/logs/", this._logQueue);
|
|
449
|
+
if (!ok) {
|
|
450
|
+
failed = true;
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
sentAny = true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
while (this._trackQueue.length > 0) {
|
|
457
|
+
const ok = await this._flushQueue("/track/", this._trackQueue);
|
|
458
|
+
if (!ok) {
|
|
459
|
+
failed = true;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
sentAny = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (sentAny && !failed) {
|
|
466
|
+
this._backoffMs = 0;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async _flushQueue(path, queue) {
|
|
472
|
+
if (queue.length === 0) return false;
|
|
473
|
+
|
|
474
|
+
const batch = queue.splice(0, this._maxBatchSize);
|
|
475
|
+
if (batch.length === 0) return false;
|
|
476
|
+
|
|
477
|
+
const ok = await this._postJSON(path, batch);
|
|
478
|
+
if (!ok) {
|
|
479
|
+
queue.unshift(...batch);
|
|
480
|
+
this._backoffMs = this._backoffMs > 0 ? Math.min(this._backoffMs * 2, 30000) : 500;
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async _postJSON(path, payload) {
|
|
487
|
+
if (typeof fetch !== "function") {
|
|
488
|
+
throw new Error("global fetch() is required (Node 18+ or provide a polyfill)");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const url = `${this._baseUrl}/api/${encodeURIComponent(this._projectId)}${path}`;
|
|
492
|
+
const json = JSON.stringify(payload);
|
|
493
|
+
const { body, contentEncoding } = await gzipIfEnabled(json, this._gzip);
|
|
494
|
+
|
|
495
|
+
/** @type {Record<string, string>} */
|
|
496
|
+
const headers = { "Content-Type": "application/json" };
|
|
497
|
+
if (this._projectKey) headers["X-Project-Key"] = this._projectKey;
|
|
498
|
+
if (contentEncoding) headers["Content-Encoding"] = contentEncoding;
|
|
499
|
+
|
|
500
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
501
|
+
const timeoutMs = this._timeoutMs > 0 ? this._timeoutMs : 0;
|
|
502
|
+
const timer = timeoutMs > 0 ? setTimeout(() => controller?.abort(), timeoutMs) : null;
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const res = await fetch(url, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers,
|
|
508
|
+
body,
|
|
509
|
+
keepalive: isBrowser(),
|
|
510
|
+
signal: controller?.signal,
|
|
511
|
+
});
|
|
512
|
+
return res.status >= 200 && res.status < 300;
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
} finally {
|
|
516
|
+
if (timer) clearTimeout(timer);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function toTimestamp(v) {
|
|
522
|
+
if (!v) return undefined;
|
|
523
|
+
if (typeof v === "string") return v;
|
|
524
|
+
if (v instanceof Date) return v.toISOString();
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "logtap-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "logtap browser/node logging + tracking SDK",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"logtap",
|
|
7
|
+
"logging",
|
|
8
|
+
"tracking",
|
|
9
|
+
"analytics",
|
|
10
|
+
"sdk",
|
|
11
|
+
"browser",
|
|
12
|
+
"node"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/aak1247/logtap.git",
|
|
17
|
+
"directory": "sdks/js/logtap"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/aak1247/logtap/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/aak1247/logtap#readme",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./index.js",
|
|
25
|
+
"types": "./index.d.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"README.md",
|
|
28
|
+
"index.d.ts",
|
|
29
|
+
"index.js"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"default": "./index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"sideEffects": false
|
|
38
|
+
}
|