summaryrefslogtreecommitdiff
path: root/packages/telemetry/src/index.ts
blob: ba7cea10846faa98b6d2ac3da4fa10eb5da245c0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { randomBytes } from 'node:crypto';
import { isCI } from 'ci-info';
import debug from 'debug';
import * as KEY from './config-keys.js';
import { GlobalConfig } from './config.js';
import { post } from './post.js';
import { type ProjectInfo, getProjectInfo } from './project-info.js';
import { type SystemInfo, getSystemInfo } from './system-info.js';

export type AstroTelemetryOptions = { astroVersion: string; viteVersion: string };
export type TelemetryEvent = { eventName: string; payload: Record<string, any> };

// In the event of significant policy changes, update this!
const VALID_TELEMETRY_NOTICE_DATE = '2023-08-25';

type EventMeta = SystemInfo;
interface EventContext extends ProjectInfo {
	anonymousId: string;
	anonymousSessionId: string;
}
export class AstroTelemetry {
	private _anonymousSessionId: string | undefined;
	private _anonymousProjectInfo: ProjectInfo | undefined;
	private config = new GlobalConfig({ name: 'astro' });
	private debug = debug('astro:telemetry');
	private isCI = isCI;
	private env = process.env;

	private get astroVersion() {
		return this.opts.astroVersion;
	}
	private get viteVersion() {
		return this.opts.viteVersion;
	}
	private get ASTRO_TELEMETRY_DISABLED() {
		return this.env.ASTRO_TELEMETRY_DISABLED;
	}
	private get TELEMETRY_DISABLED() {
		return this.env.TELEMETRY_DISABLED;
	}

	constructor(private opts: AstroTelemetryOptions) {
		// TODO: When the process exits, flush any queued promises
		// This caused a "cannot exist astro" error when it ran, so it was removed.
		// process.on('SIGINT', () => this.flush());
	}

	/**
	 * Get value from either the global config or the provided fallback.
	 * If value is not set, the fallback is saved to the global config,
	 * persisted for later sessions.
	 */
	private getConfigWithFallback<T>(key: string, getValue: () => T): T {
		const currentValue = this.config.get(key);
		if (currentValue !== undefined) {
			return currentValue;
		}
		const newValue = getValue();
		this.config.set(key, newValue);
		return newValue;
	}

	private get enabled(): boolean {
		return this.getConfigWithFallback(KEY.TELEMETRY_ENABLED, () => true);
	}

	private get notifyDate(): string {
		return this.getConfigWithFallback(KEY.TELEMETRY_NOTIFY_DATE, () => '');
	}

	private get anonymousId(): string {
		return this.getConfigWithFallback(KEY.TELEMETRY_ID, () => randomBytes(32).toString('hex'));
	}

	private get anonymousSessionId(): string {
		// NOTE(fks): this value isn't global, so it can't use getConfigWithFallback().
		this._anonymousSessionId = this._anonymousSessionId || randomBytes(32).toString('hex');
		return this._anonymousSessionId;
	}

	private get anonymousProjectInfo(): ProjectInfo {
		// NOTE(fks): this value isn't global, so it can't use getConfigWithFallback().
		this._anonymousProjectInfo = this._anonymousProjectInfo || getProjectInfo(this.isCI);
		return this._anonymousProjectInfo;
	}

	private get isDisabled(): boolean {
		if (Boolean(this.ASTRO_TELEMETRY_DISABLED || this.TELEMETRY_DISABLED)) {
			return true;
		}
		return this.enabled === false;
	}

	setEnabled(value: boolean) {
		this.config.set(KEY.TELEMETRY_ENABLED, value);
	}

	clear() {
		return this.config.clear();
	}

	isValidNotice() {
		if (!this.notifyDate) return false;
		const current = Number(this.notifyDate);
		const valid = new Date(VALID_TELEMETRY_NOTICE_DATE).valueOf();

		return current > valid;
	}

	async notify(callback: () => boolean | Promise<boolean>) {
		if (this.isDisabled || this.isCI) {
			this.debug(`[notify] telemetry has been disabled`);
			return;
		}
		// The end-user has already been notified about our telemetry integration!
		// Don't bother them about it again.
		if (this.isValidNotice()) {
			this.debug(`[notify] last notified on ${this.notifyDate}`);
			return;
		}
		const enabled = await callback();
		this.config.set(KEY.TELEMETRY_NOTIFY_DATE, new Date().valueOf().toString());
		this.config.set(KEY.TELEMETRY_ENABLED, enabled);
		this.debug(`[notify] telemetry has been ${enabled ? 'enabled' : 'disabled'}`);
	}

	async record(event: TelemetryEvent | TelemetryEvent[] = []) {
		const events: TelemetryEvent[] = Array.isArray(event) ? event : [event];
		if (events.length < 1) {
			return Promise.resolve();
		}

		// Skip recording telemetry if the feature is disabled
		if (this.isDisabled) {
			this.debug('[record] telemetry has been disabled');
			return Promise.resolve();
		}

		const meta: EventMeta = {
			...getSystemInfo({ astroVersion: this.astroVersion, viteVersion: this.viteVersion }),
		};

		const context: EventContext = {
			...this.anonymousProjectInfo,
			anonymousId: this.anonymousId,
			anonymousSessionId: this.anonymousSessionId,
		};

		// Every CI session also creates a new user, which blows up telemetry.
		// To solve this, we track all CI runs under a single "CI" anonymousId.
		if (meta.isCI) {
			context.anonymousId = `CI.${meta.ciName || 'UNKNOWN'}`;
		}

		if (this.debug.enabled) {
			// Print to standard error to simplify selecting the output
			this.debug({ context, meta });
			this.debug(JSON.stringify(events, null, 2));
			// Do not send the telemetry data if debugging. Users may use this feature
			// to preview what data would be sent.
			return Promise.resolve();
		}
		return post({
			context,
			meta,
			events,
		}).catch((err) => {
			// Log the error to the debugger, but otherwise do nothing.
			this.debug(`Error sending event: ${err.message}`);
		});
	}
}