@cockpitify/js 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -3
- package/dist/adapters.d.ts +29 -0
- package/dist/adapters.js +39 -0
- package/dist/core.d.ts +54 -0
- package/dist/core.js +308 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +33 -0
- package/dist/singleton.d.ts +18 -0
- package/dist/singleton.js +36 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +2 -0
- package/dist/web.d.ts +9 -0
- package/dist/web.js +52 -0
- package/package.json +36 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cockpitify
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @cockpitify/js
|
|
2
|
+
|
|
3
|
+
Usage analytics for web apps: page views, events and sessions, sent to the `ops` layer that
|
|
4
|
+
[Cockpitify](https://cockpitify.app) installs in your own Supabase project. You see them in the Cockpitify app.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @cockpitify/js
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { Ops } from '@cockpitify/js';
|
|
12
|
+
import { supabase } from './supabase';
|
|
13
|
+
|
|
14
|
+
Ops.init(supabase); // page views are recorded from here on
|
|
15
|
+
|
|
16
|
+
// When a feature is used:
|
|
17
|
+
Ops.track('report_exported', { rows: 120 });
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Page views come from the History API. Id-like path segments become `[id]`, so `/orders/8412` is
|
|
21
|
+
counted as `/orders/[id]`.
|
|
22
|
+
|
|
23
|
+
## Not on Supabase Auth
|
|
24
|
+
|
|
25
|
+
Send batches to your own endpoint, which verifies the user and passes the batch on to `ops.ingest_batch`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Ops, httpTransport } from '@cockpitify/js';
|
|
29
|
+
|
|
30
|
+
Ops.init(httpTransport({ endpoint: 'https://api.example.com/ops', getToken: () => auth.currentUser?.getIdToken() }));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| | |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `Ops.init(target, options?)` | `target`: a supabase-js client, an `httpTransport(...)`, or full options. Options: `appVersion`, `pageViews` (default `true`), `screenName(pathname)`, `debug`. Safe to call again. |
|
|
38
|
+
| `Ops.track(name, props?)` | An event. Props are strings, numbers or booleans. |
|
|
39
|
+
| `Ops.screen(name)` | A screen view, when you name screens yourself. |
|
|
40
|
+
| `Ops.flush()` | Sends what is queued now. |
|
|
41
|
+
| `Ops.optOut()` / `Ops.optIn()` | Stops or resumes recording on this device. |
|
|
42
|
+
|
|
43
|
+
Recording never throws into your code. Events are queued, sent in batches and retried when offline. A new session starts after 30 minutes away.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Transport } from './types.js';
|
|
2
|
+
/** The parts of a Supabase client (supabase-js v2) the SDK uses. Typed structurally: no dependency. */
|
|
3
|
+
export interface SupabaseLike {
|
|
4
|
+
rpc(fn: string, args: Record<string, unknown>): PromiseLike<{
|
|
5
|
+
data: unknown;
|
|
6
|
+
error: {
|
|
7
|
+
message?: string;
|
|
8
|
+
} | null;
|
|
9
|
+
}>;
|
|
10
|
+
auth?: {
|
|
11
|
+
onAuthStateChange?: (cb: (event: string) => void) => unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare const isSupabaseClient: (v: unknown) => v is SupabaseLike;
|
|
15
|
+
/**
|
|
16
|
+
* Supabase: `rpc('ops_ingest', { batch })` through the app's own client, so the URL, anon key and
|
|
17
|
+
* the signed-in user's session are the ones the app already uses. No other configuration.
|
|
18
|
+
*/
|
|
19
|
+
export declare function supabaseTransport(client: SupabaseLike): Transport;
|
|
20
|
+
export interface HttpTransportOptions {
|
|
21
|
+
/** Your endpoint; it verifies the token and passes the batch to ops.ingest_batch(user, batch). */
|
|
22
|
+
endpoint: string;
|
|
23
|
+
/** The signed-in user's token (Firebase ID token, your own JWT…), sent as a Bearer token. */
|
|
24
|
+
getToken?: () => Promise<string | null | undefined> | string | null | undefined;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/** Any backend: POSTs the batch as JSON. */
|
|
29
|
+
export declare function httpTransport(opts: HttpTransportOptions): Transport;
|
package/dist/adapters.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Transports: where batches go. Supabase is the first provider; any HTTP endpoint works too.
|
|
2
|
+
export const isSupabaseClient = (v) => Boolean(v) && typeof v.rpc === 'function';
|
|
3
|
+
/**
|
|
4
|
+
* Supabase: `rpc('ops_ingest', { batch })` through the app's own client, so the URL, anon key and
|
|
5
|
+
* the signed-in user's session are the ones the app already uses. No other configuration.
|
|
6
|
+
*/
|
|
7
|
+
export function supabaseTransport(client) {
|
|
8
|
+
return {
|
|
9
|
+
async send(batch) {
|
|
10
|
+
const { data, error } = await client.rpc('ops_ingest', { batch });
|
|
11
|
+
if (error)
|
|
12
|
+
throw new Error(error.message ?? 'ops_ingest failed');
|
|
13
|
+
return (data ?? { accepted: 0 });
|
|
14
|
+
},
|
|
15
|
+
onIdentityChange(flush) {
|
|
16
|
+
// Events recorded before a sign-in / sign-out go out under the session they happened in.
|
|
17
|
+
client.auth?.onAuthStateChange?.((event) => {
|
|
18
|
+
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT')
|
|
19
|
+
flush();
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Any backend: POSTs the batch as JSON. */
|
|
25
|
+
export function httpTransport(opts) {
|
|
26
|
+
return {
|
|
27
|
+
async send(batch) {
|
|
28
|
+
const token = await opts.getToken?.();
|
|
29
|
+
const res = await (opts.fetch ?? fetch)(opts.endpoint, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...opts.headers },
|
|
32
|
+
body: JSON.stringify(batch),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new Error(`HTTP ${res.status}`);
|
|
36
|
+
return (await res.json());
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OpsOptions, PropValue, WireEvent } from './types.js';
|
|
2
|
+
export declare const SESSION_GAP_MS: number;
|
|
3
|
+
export declare function randomUuid(): string;
|
|
4
|
+
/** Flat, short, scalar props; values that look like an e-mail or phone number never leave. */
|
|
5
|
+
export declare function cleanProps(props: Record<string, unknown> | undefined): Record<string, PropValue> | undefined;
|
|
6
|
+
export declare class OpsClient {
|
|
7
|
+
private readonly opts;
|
|
8
|
+
private readonly clock;
|
|
9
|
+
private readonly uuid;
|
|
10
|
+
private deviceId;
|
|
11
|
+
private queue;
|
|
12
|
+
private session;
|
|
13
|
+
private screen;
|
|
14
|
+
private config;
|
|
15
|
+
private optedOut;
|
|
16
|
+
private foreground;
|
|
17
|
+
private sending;
|
|
18
|
+
private backoffUntil;
|
|
19
|
+
private failures;
|
|
20
|
+
private timer;
|
|
21
|
+
private persistScheduled;
|
|
22
|
+
private unsubscribe;
|
|
23
|
+
readonly ready: Promise<void>;
|
|
24
|
+
constructor(opts: OpsOptions);
|
|
25
|
+
/** A feature was used. */
|
|
26
|
+
track(name: string, props?: Record<string, unknown>): void;
|
|
27
|
+
/** The app moved to a screen (route pattern, e.g. "/quiz/[id]"). The previous one is closed and timed. */
|
|
28
|
+
setScreen(name: string): void;
|
|
29
|
+
/** Sends what is queued now (also called on background). Never throws. */
|
|
30
|
+
flush(): Promise<void>;
|
|
31
|
+
/** Stops recording and drops the queue (consent withdrawn). */
|
|
32
|
+
optOut(): void;
|
|
33
|
+
optIn(): void;
|
|
34
|
+
/** The app (or tests) can drive foreground / background directly when no lifecycle is given. */
|
|
35
|
+
handleLifecycle(state: 'foreground' | 'background'): void;
|
|
36
|
+
shutdown(): void;
|
|
37
|
+
private boot;
|
|
38
|
+
private startTimer;
|
|
39
|
+
private applyConfig;
|
|
40
|
+
private recording;
|
|
41
|
+
/** Starts a session, or a new one after 30 minutes away (closing the old one at its last activity). */
|
|
42
|
+
private ensureSession;
|
|
43
|
+
private closeScreen;
|
|
44
|
+
private onBackground;
|
|
45
|
+
private onForeground;
|
|
46
|
+
private push;
|
|
47
|
+
private batch;
|
|
48
|
+
/** Writes the queue at most once per tick (many events in a row cost one write). */
|
|
49
|
+
private persist;
|
|
50
|
+
private saveSession;
|
|
51
|
+
private log;
|
|
52
|
+
/** For tests: what is waiting to be sent. */
|
|
53
|
+
pending(): readonly WireEvent[];
|
|
54
|
+
}
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// The platform-neutral client: sessions, screen timing, the device queue and sending. Platforms
|
|
2
|
+
// supply a transport, storage and lifecycle; nothing here touches a browser or React Native API.
|
|
3
|
+
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
4
|
+
const MAX_QUEUE = 1000;
|
|
5
|
+
const MAX_BATCH = 100;
|
|
6
|
+
const FLUSH_AT = 50;
|
|
7
|
+
const MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
8
|
+
const KEYS = { device: 'ops.device', queue: 'ops.queue', session: 'ops.session', optOut: 'ops.optOut' };
|
|
9
|
+
const EMAIL = /[^\s@]+@[^\s@]+\.[^\s@]+/;
|
|
10
|
+
const PHONE = /^\+?[\d\s().-]{7,}$/;
|
|
11
|
+
const systemClock = {
|
|
12
|
+
now: () => Date.now(),
|
|
13
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
14
|
+
clearInterval: (h) => clearInterval(h),
|
|
15
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
16
|
+
};
|
|
17
|
+
export function randomUuid() {
|
|
18
|
+
const c = globalThis.crypto;
|
|
19
|
+
if (c?.randomUUID)
|
|
20
|
+
return c.randomUUID();
|
|
21
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
|
22
|
+
const r = (Math.random() * 16) | 0;
|
|
23
|
+
return (ch === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Flat, short, scalar props; values that look like an e-mail or phone number never leave. */
|
|
27
|
+
export function cleanProps(props) {
|
|
28
|
+
if (!props || typeof props !== 'object')
|
|
29
|
+
return undefined;
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const key of Object.keys(props).sort().slice(0, 40)) {
|
|
32
|
+
if (Object.keys(out).length >= 20)
|
|
33
|
+
break;
|
|
34
|
+
const v = props[key];
|
|
35
|
+
if (key.length === 0 || key.length > 40)
|
|
36
|
+
continue;
|
|
37
|
+
if (typeof v === 'string')
|
|
38
|
+
out[key] = EMAIL.test(v) || PHONE.test(v) ? '[redacted]' : v.slice(0, 200);
|
|
39
|
+
else if ((typeof v === 'number' && Number.isFinite(v)) || typeof v === 'boolean')
|
|
40
|
+
out[key] = v;
|
|
41
|
+
}
|
|
42
|
+
return Object.keys(out).length ? out : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** A stable 0–1 number per device: sampling keeps a device always in or always out. */
|
|
45
|
+
function deviceFraction(id) {
|
|
46
|
+
let h = 2166136261;
|
|
47
|
+
for (let i = 0; i < id.length; i++)
|
|
48
|
+
h = Math.imul(h ^ id.charCodeAt(i), 16777619);
|
|
49
|
+
return (h >>> 0) / 4294967295;
|
|
50
|
+
}
|
|
51
|
+
async function safeGet(storage, key) {
|
|
52
|
+
try {
|
|
53
|
+
return (await storage?.getItem(key)) ?? null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function safeSet(storage, key, value) {
|
|
60
|
+
try {
|
|
61
|
+
if (value === null)
|
|
62
|
+
await storage?.removeItem?.(key);
|
|
63
|
+
else
|
|
64
|
+
await storage?.setItem(key, value);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Storage full or unavailable: the queue still works in memory.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class OpsClient {
|
|
71
|
+
opts;
|
|
72
|
+
clock;
|
|
73
|
+
uuid;
|
|
74
|
+
deviceId = '';
|
|
75
|
+
queue = [];
|
|
76
|
+
session = null;
|
|
77
|
+
screen = null;
|
|
78
|
+
config = { enabled: true, sampleRate: 1, flushSec: 30 };
|
|
79
|
+
optedOut = false;
|
|
80
|
+
foreground = true;
|
|
81
|
+
sending = false;
|
|
82
|
+
backoffUntil = 0;
|
|
83
|
+
failures = 0;
|
|
84
|
+
timer = null;
|
|
85
|
+
persistScheduled = false;
|
|
86
|
+
unsubscribe = null;
|
|
87
|
+
ready;
|
|
88
|
+
constructor(opts) {
|
|
89
|
+
this.opts = opts;
|
|
90
|
+
this.clock = opts.clock ?? systemClock;
|
|
91
|
+
this.uuid = opts.uuid ?? randomUuid;
|
|
92
|
+
this.ready = this.boot().catch((err) => this.log('boot failed', err));
|
|
93
|
+
}
|
|
94
|
+
/** A feature was used. */
|
|
95
|
+
track(name, props) {
|
|
96
|
+
void this.ready.then(() => {
|
|
97
|
+
const clean = cleanProps(props);
|
|
98
|
+
this.push({ type: 'event', name: name.trim().slice(0, 100), t: this.clock.now(), ...(clean ? { props: clean } : {}) });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** The app moved to a screen (route pattern, e.g. "/quiz/[id]"). The previous one is closed and timed. */
|
|
102
|
+
setScreen(name) {
|
|
103
|
+
void this.ready.then(() => {
|
|
104
|
+
const now = this.clock.now();
|
|
105
|
+
if (this.screen?.name === name)
|
|
106
|
+
return;
|
|
107
|
+
this.closeScreen(now);
|
|
108
|
+
if (name)
|
|
109
|
+
this.screen = { name: name.slice(0, 100), enteredAt: now };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Sends what is queued now (also called on background). Never throws. */
|
|
113
|
+
async flush() {
|
|
114
|
+
await this.ready;
|
|
115
|
+
if (this.sending || this.queue.length === 0 || this.optedOut)
|
|
116
|
+
return;
|
|
117
|
+
if (this.clock.now() < this.backoffUntil)
|
|
118
|
+
return;
|
|
119
|
+
this.sending = true;
|
|
120
|
+
const events = this.queue.slice(0, MAX_BATCH);
|
|
121
|
+
try {
|
|
122
|
+
const res = await this.opts.transport.send(this.batch(events));
|
|
123
|
+
this.applyConfig(res);
|
|
124
|
+
if (res.retryAfterSec) {
|
|
125
|
+
this.backoffUntil = this.clock.now() + res.retryAfterSec * 1000;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const sent = new Set(events.map((e) => e.id));
|
|
129
|
+
this.queue = this.queue.filter((e) => !sent.has(e.id));
|
|
130
|
+
this.failures = 0;
|
|
131
|
+
this.backoffUntil = 0;
|
|
132
|
+
this.persist();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
this.failures++;
|
|
137
|
+
this.backoffUntil = this.clock.now() + Math.min(MAX_BACKOFF_MS, 1000 * 2 ** this.failures);
|
|
138
|
+
this.log('send failed', err);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
this.sending = false;
|
|
142
|
+
}
|
|
143
|
+
if (this.queue.length >= FLUSH_AT && this.backoffUntil === 0)
|
|
144
|
+
void this.flush();
|
|
145
|
+
}
|
|
146
|
+
/** Stops recording and drops the queue (consent withdrawn). */
|
|
147
|
+
optOut() {
|
|
148
|
+
this.optedOut = true;
|
|
149
|
+
this.queue = [];
|
|
150
|
+
void safeSet(this.opts.storage, KEYS.optOut, '1');
|
|
151
|
+
void safeSet(this.opts.storage, KEYS.queue, null);
|
|
152
|
+
}
|
|
153
|
+
optIn() {
|
|
154
|
+
this.optedOut = false;
|
|
155
|
+
void safeSet(this.opts.storage, KEYS.optOut, null);
|
|
156
|
+
}
|
|
157
|
+
/** The app (or tests) can drive foreground / background directly when no lifecycle is given. */
|
|
158
|
+
handleLifecycle(state) {
|
|
159
|
+
void this.ready.then(() => (state === 'background' ? this.onBackground() : this.onForeground()));
|
|
160
|
+
}
|
|
161
|
+
shutdown() {
|
|
162
|
+
this.unsubscribe?.();
|
|
163
|
+
if (this.timer !== null)
|
|
164
|
+
this.clock.clearInterval(this.timer);
|
|
165
|
+
this.timer = null;
|
|
166
|
+
}
|
|
167
|
+
// ── internals ──
|
|
168
|
+
async boot() {
|
|
169
|
+
const storage = this.opts.storage;
|
|
170
|
+
this.deviceId = (await safeGet(storage, KEYS.device)) ?? '';
|
|
171
|
+
if (!this.deviceId) {
|
|
172
|
+
this.deviceId = this.uuid();
|
|
173
|
+
await safeSet(storage, KEYS.device, this.deviceId);
|
|
174
|
+
}
|
|
175
|
+
this.optedOut = (await safeGet(storage, KEYS.optOut)) === '1';
|
|
176
|
+
try {
|
|
177
|
+
const saved = JSON.parse((await safeGet(storage, KEYS.queue)) ?? '[]');
|
|
178
|
+
if (Array.isArray(saved))
|
|
179
|
+
this.queue = saved.slice(-MAX_QUEUE);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
this.queue = [];
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const s = JSON.parse((await safeGet(storage, KEYS.session)) ?? 'null');
|
|
186
|
+
if (s && typeof s.id === 'string')
|
|
187
|
+
this.session = s;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
this.session = null;
|
|
191
|
+
}
|
|
192
|
+
this.ensureSession(this.clock.now());
|
|
193
|
+
this.unsubscribe = this.opts.lifecycle?.subscribe((state) => this.handleLifecycle(state)) ?? null;
|
|
194
|
+
this.opts.transport.onIdentityChange?.(() => void this.flush());
|
|
195
|
+
this.startTimer();
|
|
196
|
+
}
|
|
197
|
+
startTimer() {
|
|
198
|
+
if (this.timer !== null)
|
|
199
|
+
this.clock.clearInterval(this.timer);
|
|
200
|
+
this.timer = this.clock.setInterval(() => void this.flush(), this.config.flushSec * 1000);
|
|
201
|
+
}
|
|
202
|
+
applyConfig(res) {
|
|
203
|
+
const c = res.config;
|
|
204
|
+
if (!c)
|
|
205
|
+
return;
|
|
206
|
+
const flushSec = typeof c.flushSec === 'number' && c.flushSec >= 5 ? c.flushSec : this.config.flushSec;
|
|
207
|
+
const changedTimer = flushSec !== this.config.flushSec;
|
|
208
|
+
this.config = {
|
|
209
|
+
enabled: c.enabled !== false,
|
|
210
|
+
sampleRate: typeof c.sampleRate === 'number' ? Math.min(1, Math.max(0, c.sampleRate)) : this.config.sampleRate,
|
|
211
|
+
flushSec,
|
|
212
|
+
};
|
|
213
|
+
if (!this.config.enabled) {
|
|
214
|
+
this.queue = [];
|
|
215
|
+
this.persist();
|
|
216
|
+
}
|
|
217
|
+
if (changedTimer)
|
|
218
|
+
this.startTimer();
|
|
219
|
+
}
|
|
220
|
+
recording() {
|
|
221
|
+
return !this.optedOut && this.config.enabled && deviceFraction(this.deviceId) < this.config.sampleRate;
|
|
222
|
+
}
|
|
223
|
+
/** Starts a session, or a new one after 30 minutes away (closing the old one at its last activity). */
|
|
224
|
+
ensureSession(now) {
|
|
225
|
+
const s = this.session;
|
|
226
|
+
if (s && now - s.lastActiveAt <= SESSION_GAP_MS) {
|
|
227
|
+
s.lastActiveAt = now;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (s)
|
|
231
|
+
this.push({ type: 'session_end', name: '$end', t: s.lastActiveAt, ms: Math.max(0, s.lastActiveAt - s.startedAt) }, s.id);
|
|
232
|
+
this.session = { id: this.uuid(), startedAt: now, lastActiveAt: now };
|
|
233
|
+
this.push({ type: 'session_start', name: '$start', t: now });
|
|
234
|
+
this.saveSession();
|
|
235
|
+
}
|
|
236
|
+
closeScreen(now) {
|
|
237
|
+
if (!this.screen)
|
|
238
|
+
return;
|
|
239
|
+
this.push({ type: 'screen', name: this.screen.name, t: this.screen.enteredAt, ms: Math.max(0, now - this.screen.enteredAt) });
|
|
240
|
+
this.screen = null;
|
|
241
|
+
}
|
|
242
|
+
onBackground() {
|
|
243
|
+
if (!this.foreground)
|
|
244
|
+
return;
|
|
245
|
+
this.foreground = false;
|
|
246
|
+
const now = this.clock.now();
|
|
247
|
+
const name = this.screen?.name ?? null;
|
|
248
|
+
this.closeScreen(now);
|
|
249
|
+
this.screen = name ? { name, enteredAt: now } : null;
|
|
250
|
+
if (this.session)
|
|
251
|
+
this.session.lastActiveAt = now;
|
|
252
|
+
this.saveSession();
|
|
253
|
+
void this.flush();
|
|
254
|
+
}
|
|
255
|
+
onForeground() {
|
|
256
|
+
if (this.foreground)
|
|
257
|
+
return;
|
|
258
|
+
this.foreground = true;
|
|
259
|
+
const now = this.clock.now();
|
|
260
|
+
this.ensureSession(now);
|
|
261
|
+
if (this.screen)
|
|
262
|
+
this.screen.enteredAt = now;
|
|
263
|
+
}
|
|
264
|
+
push(e, sessionId) {
|
|
265
|
+
if (!this.recording() || !e.name)
|
|
266
|
+
return;
|
|
267
|
+
if (this.foreground && this.session && e.type !== 'session_end')
|
|
268
|
+
this.session.lastActiveAt = Math.max(this.session.lastActiveAt, this.clock.now());
|
|
269
|
+
const event = { ...e, id: this.uuid(), t: new Date(e.t).toISOString(), session: sessionId ?? this.session?.id ?? this.uuid() };
|
|
270
|
+
this.queue.push(event);
|
|
271
|
+
if (this.queue.length > MAX_QUEUE)
|
|
272
|
+
this.queue.splice(0, this.queue.length - MAX_QUEUE);
|
|
273
|
+
this.persist();
|
|
274
|
+
if (this.queue.length >= FLUSH_AT)
|
|
275
|
+
void this.flush();
|
|
276
|
+
}
|
|
277
|
+
batch(events) {
|
|
278
|
+
const device = { id: this.deviceId, ...this.opts.device };
|
|
279
|
+
return {
|
|
280
|
+
v: 1,
|
|
281
|
+
sdk: this.opts.sdk ?? { name: 'ops-js', version: '0.1.0' },
|
|
282
|
+
sentAt: new Date(this.clock.now()).toISOString(),
|
|
283
|
+
device,
|
|
284
|
+
events,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Writes the queue at most once per tick (many events in a row cost one write). */
|
|
288
|
+
persist() {
|
|
289
|
+
if (this.persistScheduled)
|
|
290
|
+
return;
|
|
291
|
+
this.persistScheduled = true;
|
|
292
|
+
this.clock.setTimeout(() => {
|
|
293
|
+
this.persistScheduled = false;
|
|
294
|
+
void safeSet(this.opts.storage, KEYS.queue, JSON.stringify(this.queue));
|
|
295
|
+
}, 0);
|
|
296
|
+
}
|
|
297
|
+
saveSession() {
|
|
298
|
+
void safeSet(this.opts.storage, KEYS.session, JSON.stringify(this.session));
|
|
299
|
+
}
|
|
300
|
+
log(what, err) {
|
|
301
|
+
if (this.opts.debug)
|
|
302
|
+
console.warn(`[ops] ${what}:`, err);
|
|
303
|
+
}
|
|
304
|
+
/** For tests: what is waiting to be sent. */
|
|
305
|
+
pending() {
|
|
306
|
+
return this.queue;
|
|
307
|
+
}
|
|
308
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { OpsClient } from './core.js';
|
|
2
|
+
import { type InitTarget } from './singleton.js';
|
|
3
|
+
export { OpsClient, cleanProps, randomUuid, SESSION_GAP_MS } from './core.js';
|
|
4
|
+
export { httpTransport, supabaseTransport, isSupabaseClient, type HttpTransportOptions, type SupabaseLike } from './adapters.js';
|
|
5
|
+
export { createSingleton, resolveOptions, type InitTarget, type OpsApi } from './singleton.js';
|
|
6
|
+
export { routePattern, watchHistory } from './web.js';
|
|
7
|
+
export type * from './types.js';
|
|
8
|
+
export declare const VERSION = "0.1.0";
|
|
9
|
+
export interface WebInitOptions {
|
|
10
|
+
/** Your app's version, shown in the console's version breakdown. */
|
|
11
|
+
appVersion?: string;
|
|
12
|
+
/** Record page views automatically (default true). */
|
|
13
|
+
pageViews?: boolean;
|
|
14
|
+
/** How a path becomes a screen name; default replaces id-like segments with [id]. */
|
|
15
|
+
screenName?: (pathname: string) => string;
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare const Ops: {
|
|
19
|
+
/** A Supabase client, an `httpTransport(...)`, or full options. Safe to call again (re-initialises). */
|
|
20
|
+
init(target: InitTarget, options?: WebInitOptions): OpsClient;
|
|
21
|
+
track(name: string, props?: Record<string, unknown>): void;
|
|
22
|
+
screen(name: string): void;
|
|
23
|
+
flush(): Promise<void>;
|
|
24
|
+
optOut(): void;
|
|
25
|
+
optIn(): void;
|
|
26
|
+
client(): OpsClient | null;
|
|
27
|
+
attach(client: OpsClient): void;
|
|
28
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @cockpitify/js: usage analytics for web apps (and the shared core of the other JS SDKs).
|
|
2
|
+
//
|
|
3
|
+
// import { Ops } from '@cockpitify/js';
|
|
4
|
+
// Ops.init(supabase); // page views are recorded from here on
|
|
5
|
+
// Ops.track('report_exported', { rows: 120 });
|
|
6
|
+
import { OpsClient } from "./core.js";
|
|
7
|
+
import { createSingleton, resolveOptions } from "./singleton.js";
|
|
8
|
+
import { isBrowser, watchHistory, webDevice, webLifecycle, webStorage } from "./web.js";
|
|
9
|
+
export { OpsClient, cleanProps, randomUuid, SESSION_GAP_MS } from "./core.js";
|
|
10
|
+
export { httpTransport, supabaseTransport, isSupabaseClient } from "./adapters.js";
|
|
11
|
+
export { createSingleton, resolveOptions } from "./singleton.js";
|
|
12
|
+
export { routePattern, watchHistory } from "./web.js";
|
|
13
|
+
export const VERSION = '0.1.0';
|
|
14
|
+
const singleton = createSingleton();
|
|
15
|
+
let stopHistory = null;
|
|
16
|
+
export const Ops = {
|
|
17
|
+
...singleton,
|
|
18
|
+
/** A Supabase client, an `httpTransport(...)`, or full options. Safe to call again (re-initialises). */
|
|
19
|
+
init(target, options = {}) {
|
|
20
|
+
const browser = isBrowser();
|
|
21
|
+
const client = new OpsClient(resolveOptions(target, {
|
|
22
|
+
storage: browser ? webStorage : undefined,
|
|
23
|
+
lifecycle: browser ? webLifecycle : undefined,
|
|
24
|
+
device: browser ? webDevice(options.appVersion) : { app: options.appVersion },
|
|
25
|
+
sdk: { name: 'ops-js', version: VERSION },
|
|
26
|
+
debug: options.debug,
|
|
27
|
+
}));
|
|
28
|
+
singleton.attach(client);
|
|
29
|
+
stopHistory?.();
|
|
30
|
+
stopHistory = browser && options.pageViews !== false ? watchHistory((name) => client.setScreen(name), options.screenName) : null;
|
|
31
|
+
return client;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SupabaseLike } from './adapters.js';
|
|
2
|
+
import { OpsClient } from './core.js';
|
|
3
|
+
import type { OpsOptions, Transport } from './types.js';
|
|
4
|
+
export type InitTarget = SupabaseLike | Transport | OpsOptions;
|
|
5
|
+
/** A Supabase client, a transport, or full options → full options. */
|
|
6
|
+
export declare function resolveOptions(target: InitTarget, defaults: Omit<OpsOptions, 'transport'>): OpsOptions;
|
|
7
|
+
export interface OpsApi {
|
|
8
|
+
track(name: string, props?: Record<string, unknown>): void;
|
|
9
|
+
screen(name: string): void;
|
|
10
|
+
flush(): Promise<void>;
|
|
11
|
+
optOut(): void;
|
|
12
|
+
optIn(): void;
|
|
13
|
+
/** The live client, once initialised. */
|
|
14
|
+
client(): OpsClient | null;
|
|
15
|
+
}
|
|
16
|
+
export declare function createSingleton(): OpsApi & {
|
|
17
|
+
attach(client: OpsClient): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `Ops.track(...)` from anywhere in the app. Calls made before init are kept and replayed.
|
|
2
|
+
import { isSupabaseClient, supabaseTransport } from "./adapters.js";
|
|
3
|
+
import { OpsClient } from "./core.js";
|
|
4
|
+
/** A Supabase client, a transport, or full options → full options. */
|
|
5
|
+
export function resolveOptions(target, defaults) {
|
|
6
|
+
if (isSupabaseClient(target))
|
|
7
|
+
return { ...defaults, transport: supabaseTransport(target) };
|
|
8
|
+
if (typeof target.send === 'function')
|
|
9
|
+
return { ...defaults, transport: target };
|
|
10
|
+
const o = target;
|
|
11
|
+
return { ...defaults, ...o, device: { ...defaults.device, ...o.device } };
|
|
12
|
+
}
|
|
13
|
+
export function createSingleton() {
|
|
14
|
+
let current = null;
|
|
15
|
+
const early = [];
|
|
16
|
+
const run = (fn) => {
|
|
17
|
+
if (current)
|
|
18
|
+
fn(current);
|
|
19
|
+
else if (early.length < 200)
|
|
20
|
+
early.push(fn);
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
attach(client) {
|
|
24
|
+
current?.shutdown();
|
|
25
|
+
current = client;
|
|
26
|
+
for (const fn of early.splice(0))
|
|
27
|
+
fn(client);
|
|
28
|
+
},
|
|
29
|
+
track: (name, props) => run((c) => c.track(name, props)),
|
|
30
|
+
screen: (name) => run((c) => c.setScreen(name)),
|
|
31
|
+
flush: () => current?.flush() ?? Promise.resolve(),
|
|
32
|
+
optOut: () => run((c) => c.optOut()),
|
|
33
|
+
optIn: () => run((c) => c.optIn()),
|
|
34
|
+
client: () => current,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type EventType = 'screen' | 'event' | 'session_start' | 'session_end';
|
|
2
|
+
export type PropValue = string | number | boolean;
|
|
3
|
+
export interface WireEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
t: string;
|
|
6
|
+
type: EventType;
|
|
7
|
+
name: string;
|
|
8
|
+
session: string;
|
|
9
|
+
ms?: number;
|
|
10
|
+
props?: Record<string, PropValue>;
|
|
11
|
+
}
|
|
12
|
+
export interface DeviceInfo {
|
|
13
|
+
platform?: string;
|
|
14
|
+
os?: string;
|
|
15
|
+
app?: string;
|
|
16
|
+
locale?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface Batch {
|
|
19
|
+
v: 1;
|
|
20
|
+
sdk: {
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
};
|
|
24
|
+
sentAt: string;
|
|
25
|
+
device: DeviceInfo & {
|
|
26
|
+
id: string;
|
|
27
|
+
};
|
|
28
|
+
events: WireEvent[];
|
|
29
|
+
}
|
|
30
|
+
export interface ServerConfig {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
sampleRate: number;
|
|
33
|
+
flushSec: number;
|
|
34
|
+
}
|
|
35
|
+
export interface IngestResponse {
|
|
36
|
+
accepted: number;
|
|
37
|
+
retryAfterSec?: number;
|
|
38
|
+
config?: Partial<ServerConfig>;
|
|
39
|
+
}
|
|
40
|
+
/** Where batches go (Supabase RPC, any HTTP endpoint, …). Rejects on network or server errors. */
|
|
41
|
+
export interface Transport {
|
|
42
|
+
send(batch: Batch): Promise<IngestResponse>;
|
|
43
|
+
/** Called with a flush function so the transport can send queued events before the user changes. */
|
|
44
|
+
onIdentityChange?(flush: () => void): void;
|
|
45
|
+
}
|
|
46
|
+
/** Device storage (localStorage, AsyncStorage, …). May be slow or fail: every call is guarded. */
|
|
47
|
+
export interface Storage {
|
|
48
|
+
getItem(key: string): Promise<string | null> | string | null;
|
|
49
|
+
setItem(key: string, value: string): Promise<void> | void;
|
|
50
|
+
removeItem?(key: string): Promise<void> | void;
|
|
51
|
+
}
|
|
52
|
+
/** The app's foreground / background changes, from the platform. */
|
|
53
|
+
export interface Lifecycle {
|
|
54
|
+
subscribe(listener: (state: 'foreground' | 'background') => void): () => void;
|
|
55
|
+
}
|
|
56
|
+
export interface Clock {
|
|
57
|
+
now(): number;
|
|
58
|
+
setInterval(fn: () => void, ms: number): unknown;
|
|
59
|
+
clearInterval(handle: unknown): void;
|
|
60
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
61
|
+
}
|
|
62
|
+
export interface OpsOptions {
|
|
63
|
+
transport: Transport;
|
|
64
|
+
storage?: Storage;
|
|
65
|
+
lifecycle?: Lifecycle;
|
|
66
|
+
device?: DeviceInfo;
|
|
67
|
+
sdk?: {
|
|
68
|
+
name: string;
|
|
69
|
+
version: string;
|
|
70
|
+
};
|
|
71
|
+
clock?: Clock;
|
|
72
|
+
uuid?: () => string;
|
|
73
|
+
/** Logs SDK problems (never thrown into the app). Off by default. */
|
|
74
|
+
debug?: boolean;
|
|
75
|
+
}
|
package/dist/types.js
ADDED
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DeviceInfo, Lifecycle, Storage } from './types.js';
|
|
2
|
+
export declare const isBrowser: () => boolean;
|
|
3
|
+
export declare const webStorage: Storage;
|
|
4
|
+
export declare const webLifecycle: Lifecycle;
|
|
5
|
+
export declare function webDevice(appVersion?: string): DeviceInfo;
|
|
6
|
+
/** "/quiz/8f3c…/result" → "/quiz/[id]/result": screens are counted by route, not by record. */
|
|
7
|
+
export declare function routePattern(pathname: string): string;
|
|
8
|
+
/** Calls `onScreen` with the current route now and after every client-side navigation. */
|
|
9
|
+
export declare function watchHistory(onScreen: (name: string) => void, name?: (pathname: string) => string): () => void;
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Browsers: localStorage, tab visibility as foreground/background, and page views from the
|
|
2
|
+
// History API (React Router, Next.js, Vue Router… need nothing extra).
|
|
3
|
+
const w = globalThis;
|
|
4
|
+
export const isBrowser = () => typeof w.document !== 'undefined' && typeof w.location !== 'undefined';
|
|
5
|
+
export const webStorage = {
|
|
6
|
+
getItem: (k) => w.localStorage?.getItem(k) ?? null,
|
|
7
|
+
setItem: (k, v) => w.localStorage?.setItem(k, v),
|
|
8
|
+
removeItem: (k) => w.localStorage?.removeItem(k),
|
|
9
|
+
};
|
|
10
|
+
export const webLifecycle = {
|
|
11
|
+
subscribe(listener) {
|
|
12
|
+
const onVis = () => listener(w.document?.visibilityState === 'hidden' ? 'background' : 'foreground');
|
|
13
|
+
const onHide = () => listener('background');
|
|
14
|
+
w.document?.addEventListener?.('visibilitychange', onVis);
|
|
15
|
+
w.addEventListener?.('pagehide', onHide);
|
|
16
|
+
return () => w.document?.removeEventListener?.('visibilitychange', onVis);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export function webDevice(appVersion) {
|
|
20
|
+
const ua = w.navigator?.userAgent ?? '';
|
|
21
|
+
const os = /Windows/.test(ua) ? 'windows' : /Mac OS X/.test(ua) ? 'macos' : /Android/.test(ua) ? 'android' : /iPhone|iPad/.test(ua) ? 'ios' : /Linux/.test(ua) ? 'linux' : undefined;
|
|
22
|
+
return { platform: 'web', os, locale: w.navigator?.language, app: appVersion };
|
|
23
|
+
}
|
|
24
|
+
const ID_SEGMENT = /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|[A-Za-z0-9_-]{21,})$/i;
|
|
25
|
+
/** "/quiz/8f3c…/result" → "/quiz/[id]/result": screens are counted by route, not by record. */
|
|
26
|
+
export function routePattern(pathname) {
|
|
27
|
+
const parts = pathname.split('/').filter(Boolean).map((p) => (ID_SEGMENT.test(p) ? '[id]' : p));
|
|
28
|
+
return '/' + parts.join('/');
|
|
29
|
+
}
|
|
30
|
+
/** Calls `onScreen` with the current route now and after every client-side navigation. */
|
|
31
|
+
export function watchHistory(onScreen, name = routePattern) {
|
|
32
|
+
if (!isBrowser() || !w.history)
|
|
33
|
+
return () => { };
|
|
34
|
+
const report = () => onScreen(name(w.location.pathname));
|
|
35
|
+
const history = w.history;
|
|
36
|
+
const push = history.pushState;
|
|
37
|
+
const replace = history.replaceState;
|
|
38
|
+
history.pushState = function (...args) {
|
|
39
|
+
push.apply(this, args);
|
|
40
|
+
report();
|
|
41
|
+
};
|
|
42
|
+
history.replaceState = function (...args) {
|
|
43
|
+
replace.apply(this, args);
|
|
44
|
+
report();
|
|
45
|
+
};
|
|
46
|
+
w.addEventListener?.('popstate', report);
|
|
47
|
+
report();
|
|
48
|
+
return () => {
|
|
49
|
+
history.pushState = push;
|
|
50
|
+
history.replaceState = replace;
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cockpitify/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cockpitify usage analytics for web apps: screen views, events and sessions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Cockpitify",
|
|
7
|
+
"homepage": "https://cockpitify.app",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"cockpitify",
|
|
10
|
+
"analytics",
|
|
11
|
+
"supabase"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.6"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node ../build.mjs",
|
|
34
|
+
"test": "node --experimental-strip-types --no-warnings --test \"test/*.test.ts\"",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
36
|
+
},
|
|
37
|
+
"main": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts"
|
|
6
39
|
}
|