exe-watcher 0.2.69 → 0.2.71
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 +29 -3
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/dist/cli.js +1055 -341
- package/package.json +5 -3
- package/vendor/odd-core-analytics/LICENSE +21 -0
- package/vendor/odd-core-analytics/PROVENANCE.json +28 -0
- package/vendor/odd-core-analytics/README.md +196 -0
- package/vendor/odd-core-analytics/dist/privacy/client.d.ts +58 -0
- package/vendor/odd-core-analytics/dist/privacy/client.js +195 -0
- package/vendor/odd-core-analytics/dist/privacy/index.d.ts +2 -0
- package/vendor/odd-core-analytics/dist/privacy/index.js +18 -0
- package/vendor/odd-core-analytics/dist/privacy/schema.d.ts +46 -0
- package/vendor/odd-core-analytics/dist/privacy/schema.js +82 -0
- package/vendor/odd-core-analytics/gateway/server.mjs +128 -0
- package/vendor/odd-core-analytics/package.json +13 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exe-watcher",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "The owl watching your AI coding spend
|
|
3
|
+
"version": "0.2.71",
|
|
4
|
+
"description": "The owl watching your AI coding spend \u2014 cost, tokens, agents, and projects in your terminal and menu bar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
7
7
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"vendor/odd-core-analytics",
|
|
12
13
|
"THIRD_PARTY_NOTICES.md",
|
|
13
14
|
"!dist/**/*.map"
|
|
14
15
|
],
|
|
@@ -57,7 +58,8 @@
|
|
|
57
58
|
"tsup": "^8.4.0",
|
|
58
59
|
"tsx": "^4.19.0",
|
|
59
60
|
"typescript": "^5.8.0",
|
|
60
|
-
"vitest": "^3.1.0"
|
|
61
|
+
"vitest": "^3.1.0",
|
|
62
|
+
"@odd-core/analytics": "file:vendor/odd-core-analytics"
|
|
61
63
|
},
|
|
62
64
|
"publishConfig": {
|
|
63
65
|
"registry": "https://registry.npmjs.org"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OddBold LLC
|
|
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.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "OddCommunity/odd-core-kits/packages/analytics",
|
|
3
|
+
"revision": "3fb6fb095a1f9b1b8601965df58f08521fc38f7f",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"dirty": false,
|
|
6
|
+
"compiler": "5.9.3",
|
|
7
|
+
"sourceHashes": {
|
|
8
|
+
"package.json": "5163e68c17ac1b0296c6d2a9c556e2847ad0a961d66dc072f9810e587d343ead",
|
|
9
|
+
"README.md": "a6785accc7fafbe5355cc01d9876f47d12d290b15568ff92890f98c258f9eeb6",
|
|
10
|
+
"LICENSE": "7a9af43e5df34f24605f6d7ea7613b0982429ab12d09150a76ed681781bd964f",
|
|
11
|
+
"gateway/server.mjs": "3cacb4903bbbfecc38f2c14152ace487001c3e4e362c3ca07b086b2dc279adf2",
|
|
12
|
+
"src/privacy/index.ts": "fcd05058ad316d87938248edfb4fd7a9a1305c3176201027e602f60957455a2b",
|
|
13
|
+
"src/privacy/client.ts": "facac772a0c4767304f7e0564af387555144ed61800257ab996801be90454ddc",
|
|
14
|
+
"src/privacy/schema.ts": "a5a8ed885d6f7674d9449e66f69f2a4debda937b96a0642d97c8b4d37e5d2dd8"
|
|
15
|
+
},
|
|
16
|
+
"hashes": {
|
|
17
|
+
"README.md": "a6785accc7fafbe5355cc01d9876f47d12d290b15568ff92890f98c258f9eeb6",
|
|
18
|
+
"LICENSE": "7a9af43e5df34f24605f6d7ea7613b0982429ab12d09150a76ed681781bd964f",
|
|
19
|
+
"gateway/server.mjs": "3cacb4903bbbfecc38f2c14152ace487001c3e4e362c3ca07b086b2dc279adf2",
|
|
20
|
+
"dist/privacy/index.js": "f345eebe3947115469d4b84361d17583165c77901388e5afd668bf8442fda561",
|
|
21
|
+
"dist/privacy/index.d.ts": "fcd05058ad316d87938248edfb4fd7a9a1305c3176201027e602f60957455a2b",
|
|
22
|
+
"dist/privacy/client.js": "a476348c2934a331de8f5eea70c2a11ff3610e46ead0b0a9f334f9ef5b894564",
|
|
23
|
+
"dist/privacy/client.d.ts": "cae6e898f34ee1b3c0b04087b1c66b5abd2b9ef4c409aecfe925ee23b3e5c7e8",
|
|
24
|
+
"dist/privacy/schema.js": "e850805166766fd36dc05b71915d0e64a4f64c32fd0b21c3086bb17e778ff510",
|
|
25
|
+
"dist/privacy/schema.d.ts": "babe50be7c11c0ef016cb442cc195d5584bd98dfbf9a7a81ef99a80b5460ebbc",
|
|
26
|
+
"package.json": "ab60af99e145369ff8819a8c8b161658fe1efb7edec7d4b39a44d592e77d12e7"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Shared private PostHog + Sentry integration
|
|
2
|
+
|
|
3
|
+
Use **`@odd-core/analytics/privacy`** for Odd's self-hosted counter/error collector.
|
|
4
|
+
It is separate from the older generic API at `@odd-core/analytics` and from
|
|
5
|
+
`@odd-core/telemetry`; those APIs do not enforce this wire protocol or its defaults.
|
|
6
|
+
The privacy entry point has no runtime dependencies or provider SDKs. Its source
|
|
7
|
+
and the matching Node gateway are MIT licensed. This change does not publish npm
|
|
8
|
+
artifacts or make the containing repository public.
|
|
9
|
+
|
|
10
|
+
## New app setup
|
|
11
|
+
|
|
12
|
+
1. Create separate PostHog and Sentry projects for the app. A CLI and a native app
|
|
13
|
+
may use separate Sentry projects/aliases. Reuse the existing **build-my** server.
|
|
14
|
+
2. Register public aliases, the app slug, and static feature/source allowlists in
|
|
15
|
+
the gateway's private maps (see below). Never copy Hansel's project IDs/aliases.
|
|
16
|
+
3. Install/link `@odd-core/analytics` through the app's normal dependency process.
|
|
17
|
+
Build it with `tsc -b packages/types packages/analytics` from this repository.
|
|
18
|
+
In this workspace, use `"@odd-core/analytics": "workspace:*"`.
|
|
19
|
+
4. Supply private storage and a cryptographic UUID provider, then expose **two
|
|
20
|
+
independent opt-in controls**, both initially off. Keys/configuration alone
|
|
21
|
+
never constitute consent. Update the app's privacy policy/store disclosures.
|
|
22
|
+
5. Hook explicit features, foreground/idle state, real recording completion, and
|
|
23
|
+
handled structural errors to the client. Schedule `flush()` on a bounded timer
|
|
24
|
+
and lifecycle transitions. No timers or error hooks start automatically.
|
|
25
|
+
6. Verify real local app usage and one harmless handled error in the app's own
|
|
26
|
+
dashboards. Check opt-out, no pre-consent history, retry IDs, and tenant routing
|
|
27
|
+
before the app's normal release process. No paid/cloud build is required here.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { ActiveClock, PrivacyClient } from '@odd-core/analytics/privacy';
|
|
31
|
+
|
|
32
|
+
const analytics = await PrivacyClient.create({
|
|
33
|
+
app: 'watcher',
|
|
34
|
+
usageAlias: 'public-watcher-v1', // provision first; illustrative values
|
|
35
|
+
diagnosticsAlias: 'public-watcher-cli-v1',
|
|
36
|
+
features: ['home', 'menu'], // static developer-owned list, not user text
|
|
37
|
+
storage: privateAppStorage, // getItem/setItem, atomic durable replacement
|
|
38
|
+
randomUUID: () => crypto.randomUUID(),
|
|
39
|
+
// collector defaults to https://data.odd.community
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Only after the user accepts the relevant disclosure:
|
|
43
|
+
await analytics.setConsent({ usage: true });
|
|
44
|
+
await analytics.track({ event: 'active' }); // startup/return-to-active, not background polls
|
|
45
|
+
await analytics.track({ event: 'feature', feature: 'menu' });
|
|
46
|
+
|
|
47
|
+
const clock = new ActiveClock();
|
|
48
|
+
// Sample about every 5 seconds using a monotonic clock and actual app state.
|
|
49
|
+
// Skip/reset the clock while consent is off, after consent changes, and on sleep.
|
|
50
|
+
if (analytics.status().usage) {
|
|
51
|
+
const active_ms = clock.sample(performance.now(), isFocused, idleSeconds, isRecording);
|
|
52
|
+
if (active_ms > 0) await analytics.track({ event: 'usage', active_ms });
|
|
53
|
+
} else clock.reset();
|
|
54
|
+
|
|
55
|
+
// Recording apps only: duration from the actual completed recording, no audio/text.
|
|
56
|
+
await analytics.track({ event: 'recording', recording_ms: 3700 });
|
|
57
|
+
|
|
58
|
+
// Separate diagnostics consent, followed by a safe structural report.
|
|
59
|
+
await analytics.setConsent({ diagnostics: true });
|
|
60
|
+
await analytics.report({ event: 'error', category: 'ipc_error',
|
|
61
|
+
error_class: 'RangeError', version: '1.0.0', frames: [] });
|
|
62
|
+
await analytics.flush();
|
|
63
|
+
|
|
64
|
+
// On revocation: abort pending requests, erase the channel queue and usage ID.
|
|
65
|
+
await analytics.setConsent({ usage: false });
|
|
66
|
+
clock.reset();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`setConsent`, `track`, and `report` return a boolean: check it when displaying
|
|
70
|
+
settings or troubleshooting persistence. A storage failure disables both channels
|
|
71
|
+
in memory. If a revocation cannot be persisted, the app must keep collection off
|
|
72
|
+
on subsequent launches until it can durably save the choice. One client instance
|
|
73
|
+
owns each app state store; don't share a writable queue across processes/tabs.
|
|
74
|
+
Already accepted server events cannot be recalled by aborting a request.
|
|
75
|
+
|
|
76
|
+
## Platform adapters
|
|
77
|
+
|
|
78
|
+
| Platform | Adapter responsibilities |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| Node CLI / Electron main | Owner-only atomic file replacement (directory `0700`, files `0600`), `node:crypto.randomUUID`, explicit lifecycle flush. Renderer requests cross a narrow, validated IPC boundary. Suppress background child CLI polling. |
|
|
81
|
+
| Web | App-scoped storage, Web Crypto UUIDs, visibility/focus/idle sampling. Configure exact CORS origins on the gateway and ingress. Cross-origin cookies are omitted. Browser storage is accessible to same-origin code. |
|
|
82
|
+
| React Native / Expo | Durable app-scoped storage, platform cryptographic UUIDs, foreground state and actual activity/idle signals. Keep Node modules out of the mobile bundle. |
|
|
83
|
+
| Native Swift/Kotlin/Dart | Implement the small protocol below or bridge this client; JavaScript cannot be imported directly into Swift. Reuse the same consent/storage/lifecycle rules and gateway allowlists. |
|
|
84
|
+
|
|
85
|
+
Never install global native crash handlers or send minidumps/raw crash reports as
|
|
86
|
+
part of this kit. Sentry gets explicit sanitized structural errors. There are no
|
|
87
|
+
automatic breadcrumbs, network traces, replay, screenshots, account identification,
|
|
88
|
+
device fingerprints, prompt/transcript/audio collection, or Google analytics.
|
|
89
|
+
DAU counts consenting installations, not authenticated people. Recording minutes
|
|
90
|
+
are **N/A** for apps without a recording feature. Metric days are UTC in the pilot.
|
|
91
|
+
IP addresses necessarily reach the collection network edge; the gateway does not
|
|
92
|
+
forward incoming IP/user-agent/cookies to PostHog or Sentry. Edge logging and
|
|
93
|
+
retention are operator responsibilities; pseudonymous analytics is still data.
|
|
94
|
+
|
|
95
|
+
## Persistence and transport contract
|
|
96
|
+
|
|
97
|
+
Both queues are capped at **500 events**, expire after **7 days**, and are
|
|
98
|
+
re-sanitized on load. UUID/timestamp are assigned once before retries, preserving
|
|
99
|
+
the backend deduplication key. Each flush sends at most 50 usage and 10 errors,
|
|
100
|
+
with 5s/10s timeouts. Failures retain safe events for the next scheduled attempt.
|
|
101
|
+
Re-enabling usage after revocation creates a fresh random installation UUID.
|
|
102
|
+
The state is bound to collector/app/aliases so a config change cannot reroute an
|
|
103
|
+
old queue. App adapters must not backfill history or generate synthetic activity.
|
|
104
|
+
|
|
105
|
+
`POST /batch/`, JSON content type:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{"api_key":"public-watcher-v1","batch":[{
|
|
109
|
+
"uuid":"a1c01189-72bf-4f63-b6e6-9e940e87ebc7","timestamp":"2026-10-23T12:00:00.000Z",
|
|
110
|
+
"event":"app_feature","properties":{
|
|
111
|
+
"distinct_id":"a1c01189-72bf-4f63-b6e6-9e940e87ebc8","app":"watcher","feature":"menu"
|
|
112
|
+
}
|
|
113
|
+
}]}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Usage events: `app_active` (no counter), `app_usage` (`active_ms`, integer 1–60000),
|
|
117
|
+
`app_recording` (`recording_ms`, integer 1–86400000), `app_feature` (registered enum).
|
|
118
|
+
`POST /errors/`: same envelope/UUID/timestamp but each row has `diagnostic` instead
|
|
119
|
+
of `event`/`properties`. Diagnostic fields: `event: "error"`, fixed `category`,
|
|
120
|
+
standard JS `error_class` (unknown becomes `Error`), numeric `x.y.z` version, and
|
|
121
|
+
up to 8 registered logical frames with bounded integer line/column numbers.
|
|
122
|
+
Never put a raw exception object, message, stack string, arbitrary properties,
|
|
123
|
+
filesystem path, attachment, user identity, or DSN into either payload.
|
|
124
|
+
|
|
125
|
+
## Gateway and provisioning
|
|
126
|
+
|
|
127
|
+
The dependency-free gateway is `@odd-core/analytics/gateway` (ESM, Node 20+).
|
|
128
|
+
Run `node node_modules/@odd-core/analytics/gateway/server.mjs` in the private
|
|
129
|
+
deployment. Server-only configuration, supplied as mounted owner-only JSON files:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
// PROJECT_TOKENS_FILE
|
|
133
|
+
{"public-watcher-v1":{"app":"watcher","token":"PRIVATE_POSTHOG_PROJECT_TOKEN","features":["home","menu"]}}
|
|
134
|
+
// SENTRY_PROJECTS_FILE
|
|
135
|
+
{"public-watcher-cli-v1":{"app":"watcher","project_id":7,"public_key":"32_HEX_CHAR_PUBLIC_KEY","platform":"javascript","frames":["app:///main/index.js"]}}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Those aliases/IDs are examples, not provisioned projects. Maps route each alias
|
|
139
|
+
to a single backend project; caller-supplied project IDs, tokens, and properties
|
|
140
|
+
cannot select another tenant. Copy only public aliases into app configuration.
|
|
141
|
+
The server supports per-project `features`/`frames`; omitted lists preserve the
|
|
142
|
+
Hansel pilot defaults. The pilot gateway currently deployed in Hansel predates
|
|
143
|
+
these per-app lists: validate and deploy the shared gateway before using new
|
|
144
|
+
Watcher feature/source enums. Existing Hansel payloads remain compatible.
|
|
145
|
+
|
|
146
|
+
Optional `ALLOWED_ORIGINS` is a JSON array of exact web origins; none by default.
|
|
147
|
+
Unlisted Origin headers are rejected. Permit OPTIONS for only the two intake
|
|
148
|
+
routes at ingress when enabling web apps; do not expose dashboard routes.
|
|
149
|
+
The process binds port 8080; provision private upstreams via `POSTHOG_UPSTREAM`
|
|
150
|
+
(default `http://proxy:80/batch/`) and `SENTRY_UPSTREAM` (`http://sentry-ingress`).
|
|
151
|
+
Standalone mode restricts upstream hostnames to the private service names/local
|
|
152
|
+
host; never configure a hosted provider fallback. Rate/body/connection limits
|
|
153
|
+
remain enabled. `GET /health` does not prove end-to-end ingestion.
|
|
154
|
+
|
|
155
|
+
Existing infrastructure runbooks and provisioning helpers are in
|
|
156
|
+
`/Users/exeai/Hansel/docs/analytics/`: `APP_SETUP.md`, `STATUS.md`, `SENTRY.md`,
|
|
157
|
+
`provision-hansel.py`, `provision-sentry.py`, `create-dashboard.py`,
|
|
158
|
+
`connect-sentry-intake.py`, and `backup/README.md`. Adapt scripts without replacing
|
|
159
|
+
Hansel mappings. Actual credentials stay on the server, outside source control.
|
|
160
|
+
Dashboards: PostHog `https://build-my.tail910c31.ts.net`, Sentry
|
|
161
|
+
`https://build-my.tail910c31.ts.net:8443` (Tailscale + Tailscale DNS).
|
|
162
|
+
Encrypted daily restic backups already use Cloudflare R2, seven-day retention.
|
|
163
|
+
|
|
164
|
+
## Checks and adoption status
|
|
165
|
+
|
|
166
|
+
`pnpm --filter @odd-core/analytics test` builds the package and runs the privacy,
|
|
167
|
+
wire-contract, project isolation, CORS, and gateway tests without live services.
|
|
168
|
+
`pnpm --filter @odd-core/analytics test:privacy` runs against an existing build.
|
|
169
|
+
The legacy API remains available; adding this entry point does not automatically
|
|
170
|
+
migrate apps, deploy the gateway, create projects, or qualify Swift/mobile builds.
|
|
171
|
+
Hansel's original integration has live dogfood evidence; this shared extraction
|
|
172
|
+
requires each consuming app's normal integration checks before rollout.
|
|
173
|
+
|
|
174
|
+
## Standard basic analytics (0.2.0)
|
|
175
|
+
|
|
176
|
+
Keep collection off until the person explicitly accepts a clear first-run choice.
|
|
177
|
+
Offer **Share Basic Analytics** and **Not Now**. An unchecked optional diagnostics
|
|
178
|
+
choice can share the same screen, while usage/errors remain independently
|
|
179
|
+
controllable in Settings. Persist a decline; never repeatedly prompt or silently
|
|
180
|
+
turn on a channel after an upgrade. Existing approved choices remain approved.
|
|
181
|
+
Apps must obtain their required platform/privacy permissions before collection.
|
|
182
|
+
|
|
183
|
+
The standard product dataset is consenting-installation DAU/WAU/MAU, foreground
|
|
184
|
+
active duration, real recording duration where applicable, allowlisted feature
|
|
185
|
+
counts/outcomes, numeric app version and coarse platform. `USAGE_DISCLOSURE` and
|
|
186
|
+
`DIAGNOSTICS_DISCLOSURE` provide shared field descriptions. Supply static
|
|
187
|
+
`appVersion: '1.0.48'` and `platform: 'macos'` to PrivacyClient; platform is one of
|
|
188
|
+
macos/windows/linux/ios/android/web. Neither field comes from arbitrary event data.
|
|
189
|
+
Feature results are `{ event: 'result', feature: 'menu', outcome: 'success' }`
|
|
190
|
+
(or `failure`), sent as app_result. No error text accompanies a failure counter.
|
|
191
|
+
The gateway independently validates metadata and results. Deploy that capability
|
|
192
|
+
before enabling result events in clients; old payloads remain compatible.
|
|
193
|
+
|
|
194
|
+
DAU/WAU/MAU use the same per-app random installation ID with UTC day/7-day/30-day
|
|
195
|
+
windows; no account or cross-app ID is necessary. Existing 30-day raw retention
|
|
196
|
+
limits the rolling monthly window. Counts cover consenting installations only.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { PLATFORMS } from './schema';
|
|
2
|
+
export type Consent = {
|
|
3
|
+
usage: boolean;
|
|
4
|
+
diagnostics: boolean;
|
|
5
|
+
};
|
|
6
|
+
export interface PrivateStorage {
|
|
7
|
+
getItem(key: string): Promise<string | null>;
|
|
8
|
+
/** Must replace atomically. Native adapters must use owner-only files/encrypted app storage. */
|
|
9
|
+
setItem(key: string, value: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface PrivacyOptions {
|
|
12
|
+
app: string;
|
|
13
|
+
/** Public gateway aliases, never PostHog admin tokens or a Sentry DSN. */
|
|
14
|
+
usageAlias: string;
|
|
15
|
+
diagnosticsAlias: string;
|
|
16
|
+
features: readonly string[];
|
|
17
|
+
appVersion?: string;
|
|
18
|
+
platform?: typeof PLATFORMS[number];
|
|
19
|
+
/** Static logical source names, registered in the gateway; never filesystem paths. */
|
|
20
|
+
frames?: readonly string[];
|
|
21
|
+
collector?: string;
|
|
22
|
+
storage: PrivateStorage;
|
|
23
|
+
/** Cryptographically random UUID provider (crypto.randomUUID, expo-crypto, etc.). */
|
|
24
|
+
randomUUID: () => string;
|
|
25
|
+
send?: typeof fetch;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
/** No SDK autocapture, user context, content strings, cookies, or provider fallback. */
|
|
29
|
+
export declare class PrivacyClient {
|
|
30
|
+
private readonly options;
|
|
31
|
+
private state;
|
|
32
|
+
private serial;
|
|
33
|
+
private generation;
|
|
34
|
+
private sending;
|
|
35
|
+
private readonly origin;
|
|
36
|
+
private readonly key;
|
|
37
|
+
private readonly now;
|
|
38
|
+
private readonly features;
|
|
39
|
+
private readonly frames;
|
|
40
|
+
private constructor();
|
|
41
|
+
static create(options: PrivacyOptions): Promise<PrivacyClient>;
|
|
42
|
+
private binding;
|
|
43
|
+
private fresh;
|
|
44
|
+
private sanitize;
|
|
45
|
+
private uuid;
|
|
46
|
+
private run;
|
|
47
|
+
private persist;
|
|
48
|
+
status(): Consent;
|
|
49
|
+
/** Call only after the app presents disclosure and records an explicit user choice. */
|
|
50
|
+
setConsent(update: Partial<Consent>): Promise<boolean>;
|
|
51
|
+
track(input: unknown): Promise<boolean>;
|
|
52
|
+
report(input: unknown): Promise<boolean>;
|
|
53
|
+
private enqueue;
|
|
54
|
+
/** One bounded batch per channel; schedule retries in the app's lifecycle. */
|
|
55
|
+
flush(): Promise<void>;
|
|
56
|
+
private flushKind;
|
|
57
|
+
abort(): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PrivacyClient = void 0;
|
|
4
|
+
const schema_1 = require("./schema");
|
|
5
|
+
const WEEK = 7 * 86400000;
|
|
6
|
+
const empty = () => ({ consent: { usage: false, diagnostics: false }, id: '', usage: [], diagnostics: [] });
|
|
7
|
+
/** No SDK autocapture, user context, content strings, cookies, or provider fallback. */
|
|
8
|
+
class PrivacyClient {
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.state = empty();
|
|
12
|
+
this.serial = Promise.resolve();
|
|
13
|
+
this.generation = { usage: 0, diagnostics: 0 };
|
|
14
|
+
this.sending = {};
|
|
15
|
+
this.options = { ...options };
|
|
16
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(options.app) ||
|
|
17
|
+
![options.usageAlias, options.diagnosticsAlias].every(v => /^[a-zA-Z0-9_-]{1,128}$/.test(v)) ||
|
|
18
|
+
options.features.length > 64 || !options.features.every(v => /^[a-z][a-z0-9_]{0,63}$/.test(v)))
|
|
19
|
+
throw new Error('Invalid static app configuration');
|
|
20
|
+
if (options.appVersion !== undefined && !/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(options.appVersion))
|
|
21
|
+
throw new Error('Invalid app version');
|
|
22
|
+
if (options.platform !== undefined && !schema_1.PLATFORMS.includes(options.platform))
|
|
23
|
+
throw new Error('Invalid platform');
|
|
24
|
+
this.features = [...options.features];
|
|
25
|
+
this.frames = [...(options.frames ?? schema_1.DEFAULT_FRAMES)];
|
|
26
|
+
if (this.frames.length > 16 || !this.frames.every(v => /^app:\/\/\/[a-zA-Z0-9_/-]+\.(js|ts|swift|kt|dart)$/.test(v) && !v.includes('..')))
|
|
27
|
+
throw new Error('Invalid logical source configuration');
|
|
28
|
+
this.origin = (0, schema_1.collectorOrigin)(options.collector ?? schema_1.DEFAULT_COLLECTOR);
|
|
29
|
+
this.key = `odd-privacy-v1:${options.app}`;
|
|
30
|
+
this.now = options.now ?? Date.now;
|
|
31
|
+
}
|
|
32
|
+
static async create(options) {
|
|
33
|
+
const client = new PrivacyClient(options);
|
|
34
|
+
try {
|
|
35
|
+
const input = (0, schema_1.record)(JSON.parse(await options.storage.getItem(client.key) ?? 'null'));
|
|
36
|
+
// Binding prevents old queued data being routed to a different project's aliases.
|
|
37
|
+
if (!input || input.binding !== client.binding())
|
|
38
|
+
return client;
|
|
39
|
+
const consent = (0, schema_1.record)(input.consent);
|
|
40
|
+
client.state.consent.diagnostics = consent?.diagnostics === true;
|
|
41
|
+
client.state.consent.usage = consent?.usage === true && typeof input.id === 'string' && schema_1.UUID.test(input.id);
|
|
42
|
+
client.state.id = client.state.consent.usage ? input.id : '';
|
|
43
|
+
for (const kind of ['usage', 'diagnostics']) {
|
|
44
|
+
if (!client.state.consent[kind] || !Array.isArray(input[kind]))
|
|
45
|
+
continue;
|
|
46
|
+
for (const raw of input[kind].slice(-500)) {
|
|
47
|
+
const row = (0, schema_1.record)(raw);
|
|
48
|
+
const metric = client.sanitize(kind, row?.metric);
|
|
49
|
+
if (row && metric && typeof row.uuid === 'string' && schema_1.UUID.test(row.uuid) && client.fresh(row.timestamp)) {
|
|
50
|
+
client.state[kind].push({ uuid: row.uuid, timestamp: new Date(Date.parse(row.timestamp)).toISOString(), metric });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Remove expired/malformed fields from disk too, not only from memory.
|
|
55
|
+
await client.persist();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
client.state = empty();
|
|
59
|
+
}
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
binding() { return JSON.stringify([this.origin, this.options.app, this.options.usageAlias, this.options.diagnosticsAlias]); }
|
|
63
|
+
fresh(time) {
|
|
64
|
+
const value = typeof time === 'string' ? Date.parse(time) : NaN;
|
|
65
|
+
return Number.isFinite(value) && value <= this.now() && this.now() - value < WEEK;
|
|
66
|
+
}
|
|
67
|
+
sanitize(kind, input) {
|
|
68
|
+
return kind === 'usage' ? (0, schema_1.sanitizeMetric)(input, this.features) : (0, schema_1.sanitizeDiagnostic)(input, this.frames);
|
|
69
|
+
}
|
|
70
|
+
uuid() {
|
|
71
|
+
const id = this.options.randomUUID();
|
|
72
|
+
if (typeof id !== 'string' || !schema_1.UUID.test(id))
|
|
73
|
+
throw new Error('A cryptographic UUID provider is required');
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
run(fn) {
|
|
77
|
+
const next = this.serial.then(fn);
|
|
78
|
+
this.serial = next.catch(() => undefined);
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
async persist() {
|
|
82
|
+
try {
|
|
83
|
+
await this.options.storage.setItem(this.key, JSON.stringify({ binding: this.binding(), ...this.state }));
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
this.state = empty();
|
|
88
|
+
this.abort();
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
status() { return { ...this.state.consent }; }
|
|
93
|
+
/** Call only after the app presents disclosure and records an explicit user choice. */
|
|
94
|
+
setConsent(update) {
|
|
95
|
+
// Abort immediately, including requests blocked behind a slow storage write.
|
|
96
|
+
for (const kind of ['usage', 'diagnostics'])
|
|
97
|
+
if (update[kind] === false) {
|
|
98
|
+
this.generation[kind]++;
|
|
99
|
+
this.sending[kind]?.abort();
|
|
100
|
+
}
|
|
101
|
+
return this.run(async () => {
|
|
102
|
+
try {
|
|
103
|
+
for (const kind of ['usage', 'diagnostics'])
|
|
104
|
+
if (typeof update[kind] === 'boolean' && this.state.consent[kind] !== update[kind]) {
|
|
105
|
+
this.generation[kind]++;
|
|
106
|
+
this.sending[kind]?.abort();
|
|
107
|
+
this.state.consent[kind] = update[kind];
|
|
108
|
+
this.state[kind] = [];
|
|
109
|
+
if (kind === 'usage')
|
|
110
|
+
this.state.id = update[kind] ? this.uuid() : '';
|
|
111
|
+
}
|
|
112
|
+
return await this.persist();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
this.state = empty();
|
|
116
|
+
this.abort();
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
track(input) { return this.enqueue('usage', input); }
|
|
122
|
+
report(input) { return this.enqueue('diagnostics', input); }
|
|
123
|
+
enqueue(kind, input) {
|
|
124
|
+
// Sanitize now so caller mutation cannot add content while awaiting storage.
|
|
125
|
+
const metric = this.sanitize(kind, input);
|
|
126
|
+
const generation = this.generation[kind];
|
|
127
|
+
const allowed = this.state.consent[kind];
|
|
128
|
+
return this.run(async () => {
|
|
129
|
+
if (!allowed || !this.state.consent[kind] || generation !== this.generation[kind] || !metric)
|
|
130
|
+
return false;
|
|
131
|
+
try {
|
|
132
|
+
this.state[kind] = this.state[kind].filter(row => this.fresh(row.timestamp)).slice(-499);
|
|
133
|
+
this.state[kind].push({ uuid: this.uuid(), timestamp: new Date(this.now()).toISOString(), metric });
|
|
134
|
+
return await this.persist();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** One bounded batch per channel; schedule retries in the app's lifecycle. */
|
|
142
|
+
async flush() { await Promise.all([this.flushKind('usage'), this.flushKind('diagnostics')]); }
|
|
143
|
+
async flushKind(kind) {
|
|
144
|
+
const requestedGeneration = this.generation[kind];
|
|
145
|
+
const pending = await this.run(async () => {
|
|
146
|
+
if (this.sending[kind] || !this.state.consent[kind] || requestedGeneration !== this.generation[kind])
|
|
147
|
+
return null;
|
|
148
|
+
const before = this.state[kind].length;
|
|
149
|
+
this.state[kind] = this.state[kind].filter(row => this.fresh(row.timestamp));
|
|
150
|
+
if (before !== this.state[kind].length && !await this.persist())
|
|
151
|
+
return null;
|
|
152
|
+
const batch = this.state[kind].slice(0, kind === 'usage' ? 50 : 10);
|
|
153
|
+
if (!batch.length)
|
|
154
|
+
return null;
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
this.sending[kind] = controller;
|
|
157
|
+
return { batch, controller, generation: this.generation[kind], id: this.state.id };
|
|
158
|
+
});
|
|
159
|
+
if (!pending)
|
|
160
|
+
return;
|
|
161
|
+
const { batch, controller, generation, id } = pending;
|
|
162
|
+
const timer = setTimeout(() => controller.abort(), kind === 'usage' ? 5000 : 10000);
|
|
163
|
+
try {
|
|
164
|
+
if (controller.signal.aborted)
|
|
165
|
+
return;
|
|
166
|
+
const response = await (this.options.send ?? fetch)(`${this.origin}/${kind === 'usage' ? 'batch' : 'errors'}/`, {
|
|
167
|
+
method: 'POST', redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal,
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ api_key: kind === 'usage' ? this.options.usageAlias : this.options.diagnosticsAlias,
|
|
170
|
+
batch: batch.map(row => kind === 'diagnostics' ? { uuid: row.uuid, timestamp: row.timestamp, diagnostic: row.metric } : {
|
|
171
|
+
uuid: row.uuid, timestamp: row.timestamp, event: `app_${row.metric.event}`,
|
|
172
|
+
properties: { distinct_id: id, app: this.options.app, ...row.metric, ...(this.options.appVersion ? { app_version: this.options.appVersion } : {}),
|
|
173
|
+
...(this.options.platform ? { app_platform: this.options.platform } : {}), $process_person_profile: false, $geoip_disable: true },
|
|
174
|
+
}) }),
|
|
175
|
+
});
|
|
176
|
+
if (response.ok)
|
|
177
|
+
await this.run(async () => {
|
|
178
|
+
if (generation !== this.generation[kind])
|
|
179
|
+
return;
|
|
180
|
+
const sent = new Set(batch.map(row => row.uuid));
|
|
181
|
+
this.state[kind] = this.state[kind].filter(row => !sent.has(row.uuid));
|
|
182
|
+
await this.persist();
|
|
183
|
+
});
|
|
184
|
+
await response.body?.cancel();
|
|
185
|
+
}
|
|
186
|
+
catch { /* Retry the same IDs later; never log payloads or response bodies. */ }
|
|
187
|
+
finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
if (this.sending[kind] === controller)
|
|
190
|
+
delete this.sending[kind];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
abort() { this.sending.usage?.abort(); this.sending.diagnostics?.abort(); }
|
|
194
|
+
}
|
|
195
|
+
exports.PrivacyClient = PrivacyClient;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./schema"), exports);
|
|
18
|
+
__exportStar(require("./client"), exports);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** MIT. Counters and structural errors only; never forward caller property bags. */
|
|
2
|
+
export declare const DEFAULT_COLLECTOR = "https://data.odd.community";
|
|
3
|
+
export declare const HANSEL_FEATURES: readonly ["home", "recordings", "vocabulary", "shortcuts", "dictation"];
|
|
4
|
+
export declare const ERROR_CATEGORIES: readonly ["crash", "unhandled_exception", "render_error", "ipc_error", "ml_error"];
|
|
5
|
+
export declare const DEFAULT_FRAMES: readonly ["app:///main/index.js", "app:///renderer/index.js"];
|
|
6
|
+
export declare const USAGE_DISCLOSURE = "Share active time, recording duration, feature counts and outcomes, app version and platform with our own servers. A random ID unique to this app measures returning installations. Never audio, transcripts, prompts, account details or screen recordings.";
|
|
7
|
+
export declare const DIAGNOSTICS_DISCLOSURE = "Share sanitized error categories, app version and code locations with our own Sentry server. Never error text, account details, recordings or memory dumps.";
|
|
8
|
+
export declare const PLATFORMS: readonly ["macos", "windows", "linux", "ios", "android", "web"];
|
|
9
|
+
export type Metric = {
|
|
10
|
+
event: 'active';
|
|
11
|
+
} | {
|
|
12
|
+
event: 'usage';
|
|
13
|
+
active_ms: number;
|
|
14
|
+
} | {
|
|
15
|
+
event: 'recording';
|
|
16
|
+
recording_ms: number;
|
|
17
|
+
} | {
|
|
18
|
+
event: 'feature';
|
|
19
|
+
feature: string;
|
|
20
|
+
} | {
|
|
21
|
+
event: 'result';
|
|
22
|
+
feature: string;
|
|
23
|
+
outcome: 'success' | 'failure';
|
|
24
|
+
};
|
|
25
|
+
export type Diagnostic = {
|
|
26
|
+
event: 'error';
|
|
27
|
+
category: typeof ERROR_CATEGORIES[number];
|
|
28
|
+
error_class: string;
|
|
29
|
+
version: string;
|
|
30
|
+
frames: {
|
|
31
|
+
filename: string;
|
|
32
|
+
lineno: number;
|
|
33
|
+
colno: number;
|
|
34
|
+
}[];
|
|
35
|
+
};
|
|
36
|
+
export declare const UUID: RegExp;
|
|
37
|
+
export declare function record(input: unknown): Record<string, unknown> | null;
|
|
38
|
+
export declare function sanitizeMetric(input: unknown, features?: readonly string[]): Metric | null;
|
|
39
|
+
export declare function sanitizeDiagnostic(input: unknown, allowedFrames?: readonly string[]): Diagnostic | null;
|
|
40
|
+
export declare function collectorOrigin(input: string): string;
|
|
41
|
+
/** Sample with a monotonic clock; reset at consent/focus lifecycle boundaries. */
|
|
42
|
+
export declare class ActiveClock {
|
|
43
|
+
private previous;
|
|
44
|
+
sample(time: number, focused: boolean, idleSeconds: number, recording?: boolean): number;
|
|
45
|
+
reset(): void;
|
|
46
|
+
}
|