drengr-js 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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +24 -0
- package/dist/cjs/capture.js +378 -0
- package/dist/cjs/index.js +184 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/redact.js +351 -0
- package/dist/cjs/sink.js +276 -0
- package/dist/esm/capture.d.ts +49 -0
- package/dist/esm/capture.js +371 -0
- package/dist/esm/index.d.ts +61 -0
- package/dist/esm/index.js +181 -0
- package/dist/esm/redact.d.ts +18 -0
- package/dist/esm/redact.js +341 -0
- package/dist/esm/sink.d.ts +71 -0
- package/dist/esm/sink.js +271 -0
- package/package.json +48 -0
package/dist/esm/sink.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batches captured signals and ships them to the Drengr ingest endpoint,
|
|
3
|
+
* authenticated by a publishable key. Port of the proven Dart IngestSink,
|
|
4
|
+
* carrying both device-run lessons from birth:
|
|
5
|
+
* - delivery uses the PRE-PATCH fetch (structurally invisible to capture —
|
|
6
|
+
* the self-capture loop cannot exist);
|
|
7
|
+
* - the persistence scheduler uses the writer-loops-until-clean pattern
|
|
8
|
+
* (an overlap marks dirty and returns; never reschedules a microtask,
|
|
9
|
+
* which starved the event loop and froze the Flutter demo).
|
|
10
|
+
*
|
|
11
|
+
* Best-effort and non-blocking: never throws into the app, drops oldest on
|
|
12
|
+
* overflow, retries with exponential backoff + full jitter, persists the
|
|
13
|
+
* queue through a pluggable storage adapter (localStorage on web,
|
|
14
|
+
* AsyncStorage-compatible on React Native, in-memory fallback anywhere).
|
|
15
|
+
*/
|
|
16
|
+
import { nativeFetch, projectBody } from './capture.js';
|
|
17
|
+
import { redactBody } from './redact.js';
|
|
18
|
+
const QUEUE_KEY = 'drengr_queue_v1';
|
|
19
|
+
const BASE_BACKOFF_MS = 2000;
|
|
20
|
+
const MAX_BACKOFF_MS = 5 * 60000;
|
|
21
|
+
export function defaultStorage() {
|
|
22
|
+
try {
|
|
23
|
+
const ls = globalThis.localStorage;
|
|
24
|
+
if (ls) {
|
|
25
|
+
const probe = '__drengr_probe__';
|
|
26
|
+
ls.setItem(probe, '1');
|
|
27
|
+
ls.removeItem(probe);
|
|
28
|
+
return ls;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* private mode / RN / node — fall through */ }
|
|
32
|
+
const mem = new Map();
|
|
33
|
+
return {
|
|
34
|
+
getItem: (k) => mem.get(k) ?? null,
|
|
35
|
+
setItem: (k, v) => void mem.set(k, v),
|
|
36
|
+
removeItem: (k) => void mem.delete(k),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export class IngestSink {
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.queue = [];
|
|
42
|
+
this.timer = null;
|
|
43
|
+
this.sending = false;
|
|
44
|
+
this.retries = 0;
|
|
45
|
+
this.persistScheduled = false;
|
|
46
|
+
this.persisting = false;
|
|
47
|
+
this.persistDirty = false;
|
|
48
|
+
this.experiments = {};
|
|
49
|
+
/** Map a captured exchange to an ingest event and enqueue it. */
|
|
50
|
+
this.addNetwork = (e) => {
|
|
51
|
+
try {
|
|
52
|
+
this.enqueue(this.toNet(e));
|
|
53
|
+
}
|
|
54
|
+
catch { /* never throw into the app */ }
|
|
55
|
+
};
|
|
56
|
+
/** Sets the session's external_id (attached to every event hereafter) and emits
|
|
57
|
+
* one identify event. traits go through the same redact+project pipeline as
|
|
58
|
+
* bodies. Fail-open: invalid externalId is a no-op. */
|
|
59
|
+
this.identify = (externalId, traits) => {
|
|
60
|
+
if (typeof externalId !== 'string' || externalId.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
let redactedTraits = null;
|
|
63
|
+
try {
|
|
64
|
+
if (traits)
|
|
65
|
+
redactedTraits = projectBody(redactBody(JSON.stringify(traits)));
|
|
66
|
+
}
|
|
67
|
+
catch { /* bad traits: ship identify without them */ }
|
|
68
|
+
try {
|
|
69
|
+
this.externalId = externalId;
|
|
70
|
+
this.enqueue({
|
|
71
|
+
kind: 'identify',
|
|
72
|
+
event_id: randomId(),
|
|
73
|
+
ts_ms: Date.now(),
|
|
74
|
+
external_id: externalId,
|
|
75
|
+
...(redactedTraits != null ? { traits: redactedTraits } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch { /* never throw into the app */ }
|
|
79
|
+
};
|
|
80
|
+
/** Sets/clears a session-scoped experiment variant (attached to every event
|
|
81
|
+
* hereafter as `experiments`). variant null/empty clears the key. Fail-open. */
|
|
82
|
+
this.setExperiment = (key, variant) => {
|
|
83
|
+
try {
|
|
84
|
+
if (typeof key !== 'string' || key.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
if (!variant)
|
|
87
|
+
delete this.experiments[key];
|
|
88
|
+
else
|
|
89
|
+
this.experiments[key] = variant;
|
|
90
|
+
}
|
|
91
|
+
catch { /* never throw into the app */ }
|
|
92
|
+
};
|
|
93
|
+
this.url = opts.url;
|
|
94
|
+
this.key = opts.publishableKey;
|
|
95
|
+
this.context = opts.context;
|
|
96
|
+
this.storage = opts.storage ?? defaultStorage();
|
|
97
|
+
this.maxBatch = opts.maxBatch ?? 50;
|
|
98
|
+
this.maxQueue = opts.maxQueue ?? 500;
|
|
99
|
+
this.flushIntervalMs = opts.flushIntervalMs ?? 10000;
|
|
100
|
+
void this.restore();
|
|
101
|
+
}
|
|
102
|
+
toNet(e) {
|
|
103
|
+
const status = e.statusCode ?? 0;
|
|
104
|
+
const failed = e.errorText != null || status >= 400;
|
|
105
|
+
const reqBody = projectBody(e.requestBody);
|
|
106
|
+
const respBody = projectBody(e.responseBody);
|
|
107
|
+
return {
|
|
108
|
+
kind: failed ? 'net_fail' : 'net',
|
|
109
|
+
event_id: randomId(),
|
|
110
|
+
ts_ms: e.timestampMs,
|
|
111
|
+
method: e.method,
|
|
112
|
+
url: e.url, // already redacted by the capture layer
|
|
113
|
+
status,
|
|
114
|
+
error_kind: failed
|
|
115
|
+
? e.errorText != null
|
|
116
|
+
? 'transport'
|
|
117
|
+
: status >= 500
|
|
118
|
+
? 'server'
|
|
119
|
+
: 'client'
|
|
120
|
+
: '',
|
|
121
|
+
duration_ms: e.durationMs,
|
|
122
|
+
req_bytes: e.requestBodyBytes,
|
|
123
|
+
resp_bytes: e.responseBodyBytes,
|
|
124
|
+
...(reqBody != null ? { req_body: reqBody } : {}),
|
|
125
|
+
...(respBody != null ? { body: respBody } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
enqueue(ev) {
|
|
129
|
+
this.queue.push(ev);
|
|
130
|
+
while (this.queue.length > this.maxQueue) {
|
|
131
|
+
this.queue.shift(); // drop oldest on overflow — never block
|
|
132
|
+
}
|
|
133
|
+
this.schedulePersist();
|
|
134
|
+
if (this.retries > 0)
|
|
135
|
+
return; // backoff timer drives the flush
|
|
136
|
+
if (this.queue.length >= this.maxBatch) {
|
|
137
|
+
void this.flush();
|
|
138
|
+
}
|
|
139
|
+
else if (this.timer == null) {
|
|
140
|
+
this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async flush() {
|
|
144
|
+
if (this.timer != null) {
|
|
145
|
+
clearTimeout(this.timer);
|
|
146
|
+
this.timer = null;
|
|
147
|
+
}
|
|
148
|
+
if (this.sending || this.queue.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
this.sending = true;
|
|
151
|
+
const batch = this.queue.splice(0, 1000);
|
|
152
|
+
// sent_at_ms = device clock AT SEND: the server derives clock error from it
|
|
153
|
+
// and corrects timeline placement exactly. Set per attempt.
|
|
154
|
+
const envelope = { ...this.context, sent_at_ms: Date.now(), events: batch };
|
|
155
|
+
if (this.externalId)
|
|
156
|
+
envelope.external_id = this.externalId;
|
|
157
|
+
if (Object.keys(this.experiments).length > 0)
|
|
158
|
+
envelope.experiments = { ...this.experiments };
|
|
159
|
+
let acked = false;
|
|
160
|
+
let permanent = false;
|
|
161
|
+
try {
|
|
162
|
+
// nativeFetch: the pre-patch fetch — delivery is invisible to capture.
|
|
163
|
+
const resp = await nativeFetch()(this.url, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
authorization: `Bearer ${this.key}`,
|
|
167
|
+
'content-type': 'application/json',
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify(envelope),
|
|
170
|
+
keepalive: batch.length < 30, // survive page unload for small batches
|
|
171
|
+
});
|
|
172
|
+
acked = resp.status >= 200 && resp.status < 300;
|
|
173
|
+
// A non-retriable 4xx (revoked key 401, bad batch 400/413) will never
|
|
174
|
+
// succeed — retrying it forever head-of-line-blocks the queue and drops all
|
|
175
|
+
// newer events. Drop it. 429/408 are transient and still retry.
|
|
176
|
+
permanent = resp.status >= 400 && resp.status < 500 && resp.status !== 429 && resp.status !== 408;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
acked = false; // best-effort: never throw into the app
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
this.sending = false;
|
|
183
|
+
if (acked || permanent) {
|
|
184
|
+
this.retries = 0; // batch consumed (delivered or dropped as permanent)
|
|
185
|
+
this.schedulePersist();
|
|
186
|
+
if (this.queue.length > 0 && this.timer == null) {
|
|
187
|
+
this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Keep the batch: requeue at the front, shed newest on overflow.
|
|
192
|
+
this.queue.unshift(...batch);
|
|
193
|
+
while (this.queue.length > this.maxQueue)
|
|
194
|
+
this.queue.pop();
|
|
195
|
+
this.schedulePersist();
|
|
196
|
+
this.armBackoff();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
armBackoff() {
|
|
201
|
+
if (this.timer != null)
|
|
202
|
+
clearTimeout(this.timer);
|
|
203
|
+
const exp = BASE_BACKOFF_MS * 2 ** Math.min(this.retries, 20);
|
|
204
|
+
const capped = Math.min(exp, MAX_BACKOFF_MS);
|
|
205
|
+
const delay = BASE_BACKOFF_MS + Math.floor(Math.random() * capped);
|
|
206
|
+
this.retries++;
|
|
207
|
+
this.timer = setTimeout(() => void this.flush(), delay);
|
|
208
|
+
}
|
|
209
|
+
// --- persistence (writer-loops-until-clean; overlap marks dirty) ---
|
|
210
|
+
schedulePersist() {
|
|
211
|
+
if (this.persistScheduled)
|
|
212
|
+
return;
|
|
213
|
+
this.persistScheduled = true;
|
|
214
|
+
queueMicrotask(() => void this.persist());
|
|
215
|
+
}
|
|
216
|
+
async persist() {
|
|
217
|
+
this.persistScheduled = false;
|
|
218
|
+
if (this.persisting) {
|
|
219
|
+
this.persistDirty = true; // the in-flight writer re-snapshots
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.persisting = true;
|
|
223
|
+
try {
|
|
224
|
+
do {
|
|
225
|
+
this.persistDirty = false;
|
|
226
|
+
if (this.queue.length === 0) {
|
|
227
|
+
await this.storage.removeItem(QUEUE_KEY);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await this.storage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
|
|
231
|
+
}
|
|
232
|
+
} while (this.persistDirty);
|
|
233
|
+
}
|
|
234
|
+
catch { /* best-effort */ }
|
|
235
|
+
finally {
|
|
236
|
+
this.persisting = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async restore() {
|
|
240
|
+
try {
|
|
241
|
+
const raw = await this.storage.getItem(QUEUE_KEY);
|
|
242
|
+
if (!raw)
|
|
243
|
+
return;
|
|
244
|
+
const parsed = JSON.parse(raw);
|
|
245
|
+
if (Array.isArray(parsed)) {
|
|
246
|
+
for (const ev of parsed) {
|
|
247
|
+
if (ev && typeof ev === 'object')
|
|
248
|
+
this.queue.push(ev);
|
|
249
|
+
}
|
|
250
|
+
while (this.queue.length > this.maxQueue)
|
|
251
|
+
this.queue.shift();
|
|
252
|
+
if (this.queue.length > 0 && this.timer == null) {
|
|
253
|
+
this.timer = setTimeout(() => void this.flush(), this.flushIntervalMs);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch { /* corrupt/missing store: start empty */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function randomId() {
|
|
261
|
+
try {
|
|
262
|
+
const c = globalThis.crypto;
|
|
263
|
+
if (c?.randomUUID)
|
|
264
|
+
return c.randomUUID().replace(/-/g, '');
|
|
265
|
+
}
|
|
266
|
+
catch { /* fall through */ }
|
|
267
|
+
let out = '';
|
|
268
|
+
for (let i = 0; i < 32; i++)
|
|
269
|
+
out += Math.floor(Math.random() * 16).toString(16);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "drengr-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-code network analytics for JavaScript runtimes (Web, React Native, Electron) \u2014 one call captures every fetch/XHR exchange with secret/PII redaction in-process, no track() calls.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://drengr.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/SharminSirajudeen/drengr-community.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/cjs/index.js",
|
|
13
|
+
"module": "./dist/esm/index.js",
|
|
14
|
+
"types": "./dist/esm/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/esm/index.d.ts",
|
|
18
|
+
"import": "./dist/esm/index.js",
|
|
19
|
+
"require": "./dist/cjs/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
|
31
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
32
|
+
"prepack": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"analytics",
|
|
36
|
+
"network",
|
|
37
|
+
"observability",
|
|
38
|
+
"fetch",
|
|
39
|
+
"xhr",
|
|
40
|
+
"react-native",
|
|
41
|
+
"electron",
|
|
42
|
+
"redaction"
|
|
43
|
+
],
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"typescript": "^5.5.0",
|
|
46
|
+
"@types/node": "^24.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|