Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,24 @@ assets/

---

## Anonymous install analytics

Two independent analytics paths ship in this app, and they are easy to confuse:

| Path | Where | Identifies | Configured by |
|---|---|---|---|
| Product analytics | renderer, `posthog-js` | the signed-in Keycloak user | `VITE_POSTHOG_MINDSHUB_MAIN_PROJECT_TOKEN` |
| Install analytics | main process, `src/main/analytics.ts` | nothing, see below | nothing, the endpoint is a constant |

The main-process path sends four fire-and-forget `ANTONAPP_*` events (installer success, terms accepted, provider choice) as a single HTTP GET to `collect.mindshub.ai/collect`, where a lambda in [mindshub_services](../mindshub_services/README.md) relays them into PostHog. It mirrors `anton/analytics.py`, with one gap: `sendEvent` sends no installation fingerprint, so those events cannot be attributed to a machine and the collector groups them all under one synthetic id, flagged with an `aid_missing` property. Sending an install id would fix it, and is not done yet.

Two things worth knowing before editing this file:

- **The endpoint is baked in, and `src/main/**` has no OTA path.** A change to `ANALYTICS_URL` reaches users only when they download a new installer, which is why the constant points at a hostname we own rather than at whatever is serving behind it.
- **A blocked request is invisible.** `sendEvent` discards its response and swallows every error, so anything that rejects the request (a bot rule, a DNS change, an expired cert) loses events with nothing reporting it. That is why the request carries an explicit `User-Agent` and why the collector host is not proxied through Cloudflare. These events were in fact dropped in full for months, by an action-name filter upstream that did not match their uppercase names.

---

## IPC Reference

All channels defined in `src/shared/ipc-channels.ts`:
Expand Down
76 changes: 76 additions & 0 deletions src/main/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

/*
* `sendEvent` reaches the network through `https.get`, so the request options
* are the only observable output. Mocked rather than stubbed at the socket
* level: what matters is the host it targets and the headers it sets, both of
* which are visible in the options object.
*/
const httpsGet = vi.fn();

vi.mock('https', () => {
const get = (...args: unknown[]) => httpsGet(...args);
return { get, default: { get } };
});

import { sendEvent } from './analytics';

type GetOptions = { hostname: string; path: string; headers?: Record<string, string> };

function lastOptions(): GetOptions {
return httpsGet.mock.calls.at(-1)?.[0] as GetOptions;
}

function lastParams(): URLSearchParams {
return new URL(`https://${lastOptions().hostname}${lastOptions().path}`).searchParams;
}

beforeEach(() => {
httpsGet.mockReset();
httpsGet.mockImplementation(() => ({
on: () => undefined,
destroy: () => undefined,
}));
});

describe('sendEvent', () => {
it('reports to a host we control rather than a raw API Gateway id', () => {
// Regression guard: the previous endpoint was an
// `*.execute-api.amazonaws.com` id in an AWS account nobody could deploy
// to, so it could not be changed without shipping a new installer.
sendEvent('ANTONAPP_TERMS_ACCEPTED');

const options = lastOptions();
expect(options.hostname).toBe('collect.mindshub.ai');
expect(options.path.startsWith('/collect?')).toBe(true);
expect(options.hostname).not.toContain('execute-api');
});

it('sends an identifying user agent', () => {
// Cloudflare's bot rules answer script-shaped agents with 403 on this zone,
// and this function discards its response, so a blocked event would leave
// no trace anywhere.
sendEvent('ANTONAPP_BYOK');

const agent = lastOptions().headers?.['User-Agent'];
expect(agent).toBe('cowork-analytics/1.0');
});

it('carries the action and merges extra properties', () => {
sendEvent('ANTONAPP_INSTALLATION_SUCCESS', { step: 'verify', outcome: 'ok' });

const params = lastParams();
expect(params.get('action')).toBe('ANTONAPP_INSTALLATION_SUCCESS');
expect(params.get('step')).toBe('verify');
expect(params.get('outcome')).toBe('ok');
expect(params.get('timestamp')).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});

it('never throws when the request cannot be made', () => {
httpsGet.mockImplementation(() => {
throw new Error('getaddrinfo ENOTFOUND');
});

expect(() => sendEvent('ANTONAPP_MINDSLLM')).not.toThrow();
});
});
17 changes: 16 additions & 1 deletion src/main/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,22 @@
import * as https from 'https';
import * as url from 'url';

const ANALYTICS_URL = 'https://x6nik28qi6.execute-api.us-east-2.amazonaws.com/default/zoomInfoCollector';
/*
* A hostname we own rather than an API Gateway id, so the collector behind it
* can move without an app release to follow it. That matters more here than in
* anton: `src/main/**` has no OTA path, so a change to this constant only
* reaches users who download a new installer.
*/
const ANALYTICS_URL = 'https://collect.mindshub.ai/collect';
const TIMEOUT = 3000; // ms
/*
* Sent so the request does not arrive with Node's default agent. Cloudflare's
* bot protection answers script-shaped agents with 403 on the mindshub.ai zone,
* and this function throws its response away, so a blocked event would vanish
* with nothing reporting it. The collector host is deliberately not proxied, so
* this is a second line rather than the only one.
*/
const USER_AGENT = 'cowork-analytics/1.0';

export function sendEvent(action: string, extra?: Record<string, string>): void {
try {
Expand All @@ -35,6 +49,7 @@ export function sendEvent(action: string, extra?: Record<string, string>): void
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
timeout: TIMEOUT,
headers: { 'User-Agent': USER_AGENT },
},
(res) => { res.resume(); }
);
Expand Down
Loading