@agentsfleet/orly 0.6.9 → 0.7.1
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/audits/agents-md.md +2 -2
- package/audits/logging.sh +242 -15
- package/dispatch/write_any.md +8 -5
- package/docs/LOGGING_STANDARD.md +71 -6
- package/docs/ORLY_ARCHITECTURE.md +19 -0
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/package.json +1 -1
- package/skills/orly-babysit-prs/SKILL.md +6 -0
- package/skills/orly-spec-new/SKILL.md +6 -0
- package/skills/orly-write-integration-test/SKILL.md +6 -0
- package/skills/orly-write-unit-test/SKILL.md +6 -0
- package/src/cli.test.ts +83 -2
- package/src/cli.ts +62 -18
- package/src/install.test.ts +3 -2
- package/src/install.ts +2 -2
- package/src/telemetry.test.ts +308 -0
- package/src/telemetry.ts +350 -0
- package/src/telemetry_sync.ts +134 -0
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { open, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, arch, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const GATE_VALUE = "gate";
|
|
5
|
+
export const SKILL_INVOCATION_VALUE = "skill";
|
|
6
|
+
const SKILL_EVENT_COMMAND = "skill-event";
|
|
7
|
+
const TELEMETRY_COMMAND_VALUES = ["init", "update", "doctor", GATE_VALUE, "override", SKILL_INVOCATION_VALUE] as const;
|
|
8
|
+
const TELEMETRY_SKILL_VALUES = ["orly-babysit-prs", "orly-spec-new", "orly-write-integration-test", "orly-write-unit-test"] as const;
|
|
9
|
+
const TELEMETRY_OUTCOME_VALUES = ["success", "error", "abort"] as const;
|
|
10
|
+
export type TelemetryConsent = "off" | "anonymous";
|
|
11
|
+
export type TelemetryCommand = typeof TELEMETRY_COMMAND_VALUES[number];
|
|
12
|
+
export type TelemetryInvocation = "direct" | "hook" | typeof SKILL_INVOCATION_VALUE | "ci" | "non-interactive";
|
|
13
|
+
export type TelemetryOutcome = typeof TELEMETRY_OUTCOME_VALUES[number];
|
|
14
|
+
export type TelemetrySkill = typeof TELEMETRY_SKILL_VALUES[number];
|
|
15
|
+
export type TelemetryEvent = { schema_version: number; event_id: string; timestamp: string; command: TelemetryCommand; gate?: string; skill?: TelemetrySkill; outcome: TelemetryOutcome; failed_criterion?: string; duration_ms: number; orly_version: string; os: string; arch: string; invocation: TelemetryInvocation; session_id: string; installation_id: string };
|
|
16
|
+
export type TelemetrySession = { stateRoot: string; command: TelemetryCommand; invocation: TelemetryInvocation; skill?: TelemetrySkill; sessionId: string; installationId: string };
|
|
17
|
+
export type TelemetryObservation = { gate?: string; outcome: TelemetryOutcome; failedCriterion?: string; durationMs: number; version: string };
|
|
18
|
+
type BeginOptions = {
|
|
19
|
+
env?: Record<string, string | undefined>; home?: string; stdinIsTty?: boolean; stdoutIsTty?: boolean;
|
|
20
|
+
ask?: (message: string) => string | null; randomId?: () => string;
|
|
21
|
+
};
|
|
22
|
+
type RecordOptions = { now?: () => Date; randomId?: () => string; launchSync?: () => void };
|
|
23
|
+
const OFF_CONSENT = "off";
|
|
24
|
+
const ANONYMOUS_CONSENT = "anonymous";
|
|
25
|
+
const DIRECT_INVOCATION = "direct";
|
|
26
|
+
export const HOOK_INVOCATION_ENV = "ORLY_INVOCATION";
|
|
27
|
+
export const HOOK_INVOCATION_VALUE = "hook";
|
|
28
|
+
const CI_INVOCATION = "ci";
|
|
29
|
+
const NON_INTERACTIVE_INVOCATION = "non-interactive";
|
|
30
|
+
const STATE_DIRECTORY_ENV = "AGENTSFLEET_STATE_DIR";
|
|
31
|
+
const CONSENT_OVERRIDE_ENV = "ORLY_TELEMETRY";
|
|
32
|
+
const HARD_OFF_ENV = "ORLY_TELEMETRY_OFF";
|
|
33
|
+
const CONFIG_DIRECTORY = ".config";
|
|
34
|
+
const AGENTSFLEET_DIRECTORY = "agentsfleet";
|
|
35
|
+
const ORLY_DIRECTORY = "orly";
|
|
36
|
+
const CONFIG_FILE = ".orly.json";
|
|
37
|
+
const PROMPT_MARKER_FILE = ".telemetry-prompted";
|
|
38
|
+
const INSTALLATION_ID_FILE = "installation-id";
|
|
39
|
+
const ANALYTICS_DIRECTORY = "analytics";
|
|
40
|
+
const SPOOL_FILE = "orly-usage.jsonl";
|
|
41
|
+
const LOCK_FILE = ".spool-lock";
|
|
42
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
43
|
+
const UTF8_ENCODING = "utf8", NEWLINE = "\n", UNKNOWN_VERSION = "unknown";
|
|
44
|
+
const EXCLUSIVE_CREATE_MODE = "wx", IGNORE_IO = "ignore";
|
|
45
|
+
const SCHEMA_VERSION = 1;
|
|
46
|
+
const FILE_MODE = 0o600, DIRECTORY_MODE = 0o700;
|
|
47
|
+
const MAX_SPOOL_BYTES = 10 * 1024 * 1024, RETENTION_MILLISECONDS = 7 * 24 * 60 * 60 * 1_000, STALE_LOCK_MILLISECONDS = 60_000;
|
|
48
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
49
|
+
const TELEMETRY_COMMANDS: ReadonlySet<string> = new Set(TELEMETRY_COMMAND_VALUES);
|
|
50
|
+
const TELEMETRY_SKILLS: ReadonlySet<string> = new Set(TELEMETRY_SKILL_VALUES);
|
|
51
|
+
const TELEMETRY_OUTCOMES: ReadonlySet<string> = new Set(TELEMETRY_OUTCOME_VALUES);
|
|
52
|
+
const EVENT_KEYS = new Set([
|
|
53
|
+
"schema_version", "event_id", "timestamp", "command", GATE_VALUE, "skill", "outcome", "failed_criterion", "duration_ms",
|
|
54
|
+
"orly_version", "os", "arch", "invocation", "session_id", "installation_id",
|
|
55
|
+
]);
|
|
56
|
+
export const TELEMETRY_PROMPT = `Help improve Orly by sharing anonymous usage telemetry with PostHog?
|
|
57
|
+
|
|
58
|
+
If enabled, Orly records and sends only:
|
|
59
|
+
- command, gate, and packaged Orly skill names
|
|
60
|
+
- success, error, or abort outcome and the failed gate criterion
|
|
61
|
+
- duration, Orly version, operating system, CPU architecture, and invocation type
|
|
62
|
+
- timestamps and random event, session, and installation IDs
|
|
63
|
+
(the installation ID persists so we can connect runs from the same Orly install)
|
|
64
|
+
|
|
65
|
+
Orly never collects or sends:
|
|
66
|
+
- source code, file contents, prompts, or command argument values
|
|
67
|
+
- file paths, working directory, repository names, or branch names
|
|
68
|
+
- environment variables, command output, or raw error messages
|
|
69
|
+
- name, email, username, hostname, account details, credentials, or tokens
|
|
70
|
+
|
|
71
|
+
1. Off — write and send no telemetry (default)
|
|
72
|
+
2. Anonymous — record and send only the fields listed above
|
|
73
|
+
|
|
74
|
+
Choose 1 or 2`;
|
|
75
|
+
export async function beginTelemetry(args: string[], options: BeginOptions = {}): Promise<TelemetrySession | undefined> {
|
|
76
|
+
try {
|
|
77
|
+
const subject = telemetrySubject(args);
|
|
78
|
+
if (!subject) return undefined;
|
|
79
|
+
const env = options.env ?? process.env;
|
|
80
|
+
const stateRoot = telemetryStateRoot(env, options.home);
|
|
81
|
+
const invocation = telemetryInvocation(env, options.stdinIsTty, options.stdoutIsTty);
|
|
82
|
+
const persisted = await readPersistedConsent(stateRoot);
|
|
83
|
+
const override = env[CONSENT_OVERRIDE_ENV];
|
|
84
|
+
let consent = resolveTelemetryConsent(env, persisted);
|
|
85
|
+
if (env[HARD_OFF_ENV] !== "1" && override === undefined && persisted === undefined && invocation === DIRECT_INVOCATION) {
|
|
86
|
+
consent = await promptForConsent(stateRoot, options.ask);
|
|
87
|
+
}
|
|
88
|
+
if (consent !== ANONYMOUS_CONSENT) return undefined;
|
|
89
|
+
const randomId = options.randomId ?? (() => crypto.randomUUID());
|
|
90
|
+
return {
|
|
91
|
+
stateRoot,
|
|
92
|
+
command: subject.command,
|
|
93
|
+
invocation,
|
|
94
|
+
...(subject.skill ? { skill: subject.skill } : {}),
|
|
95
|
+
sessionId: randomId(),
|
|
96
|
+
installationId: await installationId(stateRoot, randomId),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function resolveTelemetryConsent(env: Record<string, string | undefined>, persisted?: TelemetryConsent): TelemetryConsent {
|
|
103
|
+
if (env[HARD_OFF_ENV] === "1") return OFF_CONSENT;
|
|
104
|
+
const override = env[CONSENT_OVERRIDE_ENV];
|
|
105
|
+
if (override !== undefined) return isTelemetryConsent(override) ? override : OFF_CONSENT;
|
|
106
|
+
return persisted ?? OFF_CONSENT;
|
|
107
|
+
}
|
|
108
|
+
export function telemetryStateRoot(env: Record<string, string | undefined>, home = homedir()): string {
|
|
109
|
+
const agentsfleetRoot = env[STATE_DIRECTORY_ENV]?.trim() || join(home, CONFIG_DIRECTORY, AGENTSFLEET_DIRECTORY);
|
|
110
|
+
return join(agentsfleetRoot, ORLY_DIRECTORY);
|
|
111
|
+
}
|
|
112
|
+
export async function recordTelemetry(session: TelemetrySession | undefined, observation: TelemetryObservation, options: RecordOptions = {}): Promise<void> {
|
|
113
|
+
if (!session) return;
|
|
114
|
+
try {
|
|
115
|
+
const now = (options.now ?? (() => new Date()))();
|
|
116
|
+
const event = createTelemetryEvent(session, observation, now, options.randomId ?? (() => crypto.randomUUID()));
|
|
117
|
+
const appended = await withSpoolLock(session.stateRoot, async () => {
|
|
118
|
+
const events = await readTelemetrySpool(session.stateRoot, now);
|
|
119
|
+
await replaceTelemetrySpool(session.stateRoot, [...events, event], now);
|
|
120
|
+
});
|
|
121
|
+
if (!appended) return;
|
|
122
|
+
(options.launchSync ?? launchTelemetrySync)();
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export async function readPersistedConsent(stateRoot: string): Promise<TelemetryConsent | undefined> {
|
|
128
|
+
try {
|
|
129
|
+
const value: unknown = JSON.parse(await readFile(join(stateRoot, CONFIG_FILE), UTF8_ENCODING));
|
|
130
|
+
if (!isObject(value) || !isTelemetryConsent(value.telemetry)) return undefined;
|
|
131
|
+
return value.telemetry;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function readTelemetrySpool(stateRoot: string, now = new Date()): Promise<TelemetryEvent[]> {
|
|
137
|
+
const path = telemetrySpoolPath(stateRoot);
|
|
138
|
+
try {
|
|
139
|
+
const details = await stat(path);
|
|
140
|
+
const start = Math.max(0, details.size - MAX_SPOOL_BYTES);
|
|
141
|
+
const bytes = await Bun.file(path).slice(start).text();
|
|
142
|
+
const bounded = start === 0 ? bytes : bytes.slice(Math.max(0, bytes.indexOf(NEWLINE) + 1));
|
|
143
|
+
const cutoff = now.getTime() - RETENTION_MILLISECONDS;
|
|
144
|
+
return bounded.split(NEWLINE).flatMap((line) => {
|
|
145
|
+
if (!line) return [];
|
|
146
|
+
const event = parseTelemetryEvent(line);
|
|
147
|
+
return event && Date.parse(event.timestamp) >= cutoff ? [event] : [];
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function replaceTelemetrySpool(stateRoot: string, events: TelemetryEvent[], now = new Date()): Promise<void> {
|
|
154
|
+
const path = telemetrySpoolPath(stateRoot);
|
|
155
|
+
const lines = boundedEventLines(events, now);
|
|
156
|
+
await atomicWrite(path, lines.length === 0 ? "" : `${lines.join(NEWLINE)}${NEWLINE}`);
|
|
157
|
+
}
|
|
158
|
+
export async function withSpoolLock(stateRoot: string, action: () => Promise<void>): Promise<boolean> {
|
|
159
|
+
const lockPath = join(stateRoot, ANALYTICS_DIRECTORY, LOCK_FILE);
|
|
160
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: DIRECTORY_MODE });
|
|
161
|
+
const handle = await acquireLock(lockPath);
|
|
162
|
+
if (!handle) return false;
|
|
163
|
+
try {
|
|
164
|
+
await action();
|
|
165
|
+
return true;
|
|
166
|
+
} finally {
|
|
167
|
+
await handle.close().catch(() => undefined);
|
|
168
|
+
await unlink(lockPath).catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function parseTelemetryEvent(line: string): TelemetryEvent | undefined {
|
|
172
|
+
try {
|
|
173
|
+
const value: unknown = JSON.parse(line);
|
|
174
|
+
if (!isObject(value) || Object.keys(value).some((key) => !EVENT_KEYS.has(key))) return undefined;
|
|
175
|
+
if (value.schema_version !== SCHEMA_VERSION || !isUuid(value.event_id) || !isTimestamp(value.timestamp)) return undefined;
|
|
176
|
+
if (!isTelemetryCommand(value.command) || !isTelemetryOutcome(value.outcome) || !isTelemetryInvocation(value.invocation)) return undefined;
|
|
177
|
+
if (!isNonNegativeNumber(value.duration_ms) || !isString(value.orly_version) || !isString(value.os) || !isString(value.arch)) return undefined;
|
|
178
|
+
if (!isUuid(value.session_id) || !isUuid(value.installation_id)) return undefined;
|
|
179
|
+
if (!isOptionalString(value.gate) || !isOptionalString(value.failed_criterion)) return undefined;
|
|
180
|
+
if (value.command === SKILL_INVOCATION_VALUE ? !isTelemetrySkill(value.skill) : value.skill !== undefined) return undefined;
|
|
181
|
+
return value as TelemetryEvent;
|
|
182
|
+
} catch {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export async function installedOrlyVersion(root: string): Promise<string> {
|
|
187
|
+
try {
|
|
188
|
+
const value: unknown = JSON.parse(await readFile(join(root, PACKAGE_MANIFEST), UTF8_ENCODING));
|
|
189
|
+
return isObject(value) && isString(value.version) ? value.version : UNKNOWN_VERSION;
|
|
190
|
+
} catch {
|
|
191
|
+
return UNKNOWN_VERSION;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function promptForConsent(stateRoot: string, ask = (message: string) => prompt(message)): Promise<TelemetryConsent> {
|
|
195
|
+
const marker = join(stateRoot, PROMPT_MARKER_FILE);
|
|
196
|
+
if (await fileExists(marker)) return OFF_CONSENT;
|
|
197
|
+
try {
|
|
198
|
+
await atomicWrite(marker, "");
|
|
199
|
+
} catch {
|
|
200
|
+
return OFF_CONSENT;
|
|
201
|
+
}
|
|
202
|
+
let answer: string | null = null;
|
|
203
|
+
try {
|
|
204
|
+
answer = ask(TELEMETRY_PROMPT);
|
|
205
|
+
} catch {
|
|
206
|
+
answer = null;
|
|
207
|
+
}
|
|
208
|
+
const consent = answer?.trim() === "2" ? ANONYMOUS_CONSENT : OFF_CONSENT;
|
|
209
|
+
try {
|
|
210
|
+
await atomicWrite(join(stateRoot, CONFIG_FILE), `${JSON.stringify({ telemetry: consent })}\n`);
|
|
211
|
+
return consent;
|
|
212
|
+
} catch {
|
|
213
|
+
return OFF_CONSENT;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function installationId(stateRoot: string, randomId: () => string): Promise<string> {
|
|
217
|
+
const path = join(stateRoot, INSTALLATION_ID_FILE);
|
|
218
|
+
try {
|
|
219
|
+
const existing = (await readFile(path, UTF8_ENCODING)).trim();
|
|
220
|
+
if (isUuid(existing)) return existing;
|
|
221
|
+
} catch {
|
|
222
|
+
// A missing or malformed identity is replaced by a random value below.
|
|
223
|
+
}
|
|
224
|
+
const generated = randomId();
|
|
225
|
+
await atomicWrite(path, `${generated}${NEWLINE}`);
|
|
226
|
+
return generated;
|
|
227
|
+
}
|
|
228
|
+
function createTelemetryEvent(session: TelemetrySession, observation: TelemetryObservation, now: Date, randomId: () => string): TelemetryEvent {
|
|
229
|
+
return {
|
|
230
|
+
schema_version: SCHEMA_VERSION,
|
|
231
|
+
event_id: randomId(),
|
|
232
|
+
timestamp: now.toISOString(),
|
|
233
|
+
command: session.command,
|
|
234
|
+
...(observation.gate ? { gate: observation.gate } : {}),
|
|
235
|
+
...(session.skill ? { skill: session.skill } : {}),
|
|
236
|
+
outcome: observation.outcome,
|
|
237
|
+
...(observation.failedCriterion ? { failed_criterion: observation.failedCriterion } : {}),
|
|
238
|
+
duration_ms: Math.max(0, Math.round(observation.durationMs)),
|
|
239
|
+
orly_version: observation.version,
|
|
240
|
+
os: platform(),
|
|
241
|
+
arch: arch(),
|
|
242
|
+
invocation: session.invocation,
|
|
243
|
+
session_id: session.sessionId,
|
|
244
|
+
installation_id: session.installationId,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function telemetrySubject(args: string[]): { command: TelemetryCommand; skill?: TelemetrySkill } | undefined {
|
|
248
|
+
const command = args[0];
|
|
249
|
+
if (command === SKILL_EVENT_COMMAND) {
|
|
250
|
+
const skill = args[1];
|
|
251
|
+
return args.length === 2 && isTelemetrySkill(skill) ? { command: SKILL_INVOCATION_VALUE, skill } : undefined;
|
|
252
|
+
}
|
|
253
|
+
return command && isTelemetryCommand(command) && command !== SKILL_INVOCATION_VALUE ? { command } : undefined;
|
|
254
|
+
}
|
|
255
|
+
function telemetryInvocation(env: Record<string, string | undefined>, stdinIsTty = process.stdin.isTTY === true, stdoutIsTty = process.stdout.isTTY === true): TelemetryInvocation {
|
|
256
|
+
if (env[HOOK_INVOCATION_ENV] === HOOK_INVOCATION_VALUE) return HOOK_INVOCATION_VALUE;
|
|
257
|
+
if (env[HOOK_INVOCATION_ENV] === SKILL_INVOCATION_VALUE) return SKILL_INVOCATION_VALUE;
|
|
258
|
+
if (env.CI && env.CI !== "false" && env.CI !== "0") return CI_INVOCATION;
|
|
259
|
+
return stdinIsTty && stdoutIsTty ? DIRECT_INVOCATION : NON_INTERACTIVE_INVOCATION;
|
|
260
|
+
}
|
|
261
|
+
function boundedEventLines(events: TelemetryEvent[], now: Date): string[] {
|
|
262
|
+
const cutoff = now.getTime() - RETENTION_MILLISECONDS;
|
|
263
|
+
const eligible = events.filter((event) => Date.parse(event.timestamp) >= cutoff).map((event) => JSON.stringify(event));
|
|
264
|
+
const selected: string[] = [];
|
|
265
|
+
let bytes = 0;
|
|
266
|
+
for (let index = eligible.length - 1; index >= 0; index -= 1) {
|
|
267
|
+
const line = eligible[index];
|
|
268
|
+
if (line === undefined) continue;
|
|
269
|
+
const lineBytes = Buffer.byteLength(line) + 1;
|
|
270
|
+
if (bytes + lineBytes > MAX_SPOOL_BYTES) break;
|
|
271
|
+
selected.unshift(line);
|
|
272
|
+
bytes += lineBytes;
|
|
273
|
+
}
|
|
274
|
+
return selected;
|
|
275
|
+
}
|
|
276
|
+
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>> | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
return await open(path, EXCLUSIVE_CREATE_MODE, FILE_MODE);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (!isErrorCode(error, "EEXIST")) return undefined;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const details = await stat(path);
|
|
284
|
+
if (Date.now() - details.mtimeMs <= STALE_LOCK_MILLISECONDS) return undefined;
|
|
285
|
+
await unlink(path);
|
|
286
|
+
return await open(path, EXCLUSIVE_CREATE_MODE, FILE_MODE);
|
|
287
|
+
} catch {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
292
|
+
await mkdir(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
|
|
293
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
294
|
+
try {
|
|
295
|
+
await writeFile(temporary, content, { mode: FILE_MODE });
|
|
296
|
+
await rename(temporary, path);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await unlink(temporary).catch(() => undefined);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function launchTelemetrySync(): void {
|
|
303
|
+
const child = Bun.spawn([process.execPath, join(import.meta.dir, "telemetry_sync.ts")], {
|
|
304
|
+
stdin: IGNORE_IO,
|
|
305
|
+
stdout: IGNORE_IO,
|
|
306
|
+
stderr: IGNORE_IO,
|
|
307
|
+
env: process.env,
|
|
308
|
+
});
|
|
309
|
+
child.unref();
|
|
310
|
+
}
|
|
311
|
+
function telemetrySpoolPath(stateRoot: string): string {
|
|
312
|
+
return join(stateRoot, ANALYTICS_DIRECTORY, SPOOL_FILE);
|
|
313
|
+
}
|
|
314
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
315
|
+
try {
|
|
316
|
+
await stat(path);
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function isTelemetryConsent(value: unknown): value is TelemetryConsent {
|
|
323
|
+
return value === OFF_CONSENT || value === ANONYMOUS_CONSENT;
|
|
324
|
+
}
|
|
325
|
+
function isTelemetryCommand(value: unknown): value is TelemetryCommand {
|
|
326
|
+
return isStringValue(value) && TELEMETRY_COMMANDS.has(value);
|
|
327
|
+
}
|
|
328
|
+
export function isTelemetrySkill(value: unknown): value is TelemetrySkill {
|
|
329
|
+
return isStringValue(value) && TELEMETRY_SKILLS.has(value);
|
|
330
|
+
}
|
|
331
|
+
function isTelemetryOutcome(value: unknown): value is TelemetryOutcome {
|
|
332
|
+
return isStringValue(value) && TELEMETRY_OUTCOMES.has(value);
|
|
333
|
+
}
|
|
334
|
+
function isTelemetryInvocation(value: unknown): value is TelemetryInvocation {
|
|
335
|
+
return value === DIRECT_INVOCATION || value === HOOK_INVOCATION_VALUE || value === SKILL_INVOCATION_VALUE || value === CI_INVOCATION || value === NON_INTERACTIVE_INVOCATION;
|
|
336
|
+
}
|
|
337
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
338
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
339
|
+
}
|
|
340
|
+
function isString(value: unknown): value is string {
|
|
341
|
+
return isStringValue(value) && value.length > 0;
|
|
342
|
+
}
|
|
343
|
+
function isOptionalString(value: unknown): boolean {
|
|
344
|
+
return value === undefined || isString(value);
|
|
345
|
+
}
|
|
346
|
+
function isTimestamp(value: unknown): value is string { return isString(value) && Number.isFinite(Date.parse(value)); }
|
|
347
|
+
function isNonNegativeNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
|
|
348
|
+
function isErrorCode(error: unknown, code: string): boolean { return isObject(error) && error.code === code; }
|
|
349
|
+
function isUuid(value: unknown): value is string { return isStringValue(value) && UUID_PATTERN.test(value); }
|
|
350
|
+
function isStringValue(value: unknown): value is string { return typeof value === "string"; }
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type TelemetryEvent,
|
|
6
|
+
readPersistedConsent,
|
|
7
|
+
readTelemetrySpool,
|
|
8
|
+
replaceTelemetrySpool,
|
|
9
|
+
resolveTelemetryConsent,
|
|
10
|
+
telemetryStateRoot,
|
|
11
|
+
withSpoolLock,
|
|
12
|
+
} from "./telemetry";
|
|
13
|
+
|
|
14
|
+
type SyncOptions = {
|
|
15
|
+
env?: Record<string, string | undefined>;
|
|
16
|
+
home?: string;
|
|
17
|
+
now?: () => Date;
|
|
18
|
+
fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type PendingBatch = {
|
|
22
|
+
stateRoot: string;
|
|
23
|
+
events: TelemetryEvent[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ANALYTICS_DIRECTORY = "analytics";
|
|
27
|
+
const LAST_ATTEMPT_FILE = ".last-sync-time";
|
|
28
|
+
const POSTHOG_HOST_ENV = "ORLY_POSTHOG_HOST";
|
|
29
|
+
const POSTHOG_KEY_ENV = "ORLY_POSTHOG_KEY";
|
|
30
|
+
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
31
|
+
const DEFAULT_POSTHOG_KEY = "phc_qhwWd6d67T4X4yDjQNeuBqrMKAPstGUeQtKLFF9t8fEF"; // gitleaks:allow — public capture-only project key
|
|
32
|
+
const POSTHOG_BATCH_PATH = "/batch";
|
|
33
|
+
const COMMAND_EVENT_NAME = "orly_command_run";
|
|
34
|
+
const SKILL_EVENT_NAME = "orly_skill_run";
|
|
35
|
+
const BATCH_LIMIT = 100;
|
|
36
|
+
const ATTEMPT_INTERVAL_MILLISECONDS = 5 * 60 * 1_000;
|
|
37
|
+
const REQUEST_TIMEOUT_MILLISECONDS = 3_000;
|
|
38
|
+
|
|
39
|
+
export async function syncTelemetry(options: SyncOptions = {}): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const env = options.env ?? process.env;
|
|
42
|
+
const stateRoot = telemetryStateRoot(env, options.home);
|
|
43
|
+
const persisted = await readPersistedConsent(stateRoot);
|
|
44
|
+
if (resolveTelemetryConsent(env, persisted) !== "anonymous") return;
|
|
45
|
+
const now = (options.now ?? (() => new Date()))();
|
|
46
|
+
const pending = await pendingBatch(stateRoot, now);
|
|
47
|
+
if (!pending || pending.events.length === 0) return;
|
|
48
|
+
const response = await postBatch(pending.events, env, options.fetchImpl ?? fetch);
|
|
49
|
+
if (!response.ok) return;
|
|
50
|
+
await compactAcknowledgedPrefix(pending, now);
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pendingBatch(stateRoot: string, now: Date): Promise<PendingBatch | undefined> {
|
|
57
|
+
let pending: PendingBatch | undefined;
|
|
58
|
+
await withSpoolLock(stateRoot, async () => {
|
|
59
|
+
const events = await readTelemetrySpool(stateRoot, now);
|
|
60
|
+
await replaceTelemetrySpool(stateRoot, events, now);
|
|
61
|
+
if (await attemptedRecently(stateRoot, now)) return;
|
|
62
|
+
await writeFile(lastAttemptPath(stateRoot), `${now.toISOString()}\n`, { mode: 0o600 });
|
|
63
|
+
pending = { stateRoot, events: events.slice(0, BATCH_LIMIT) };
|
|
64
|
+
});
|
|
65
|
+
return pending;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function postBatch(events: TelemetryEvent[], env: Record<string, string | undefined>, fetchImpl: NonNullable<SyncOptions["fetchImpl"]>): Promise<Response> {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MILLISECONDS);
|
|
71
|
+
try {
|
|
72
|
+
return await fetchImpl(new URL(POSTHOG_BATCH_PATH, env[POSTHOG_HOST_ENV] || DEFAULT_POSTHOG_HOST), {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ api_key: env[POSTHOG_KEY_ENV] || DEFAULT_POSTHOG_KEY, batch: events.map(postHogEvent) }),
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
});
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function postHogEvent(event: TelemetryEvent): Record<string, unknown> {
|
|
84
|
+
return {
|
|
85
|
+
event: event.skill ? SKILL_EVENT_NAME : COMMAND_EVENT_NAME,
|
|
86
|
+
timestamp: event.timestamp,
|
|
87
|
+
properties: {
|
|
88
|
+
distinct_id: event.installation_id,
|
|
89
|
+
$insert_id: event.event_id,
|
|
90
|
+
schema_version: event.schema_version,
|
|
91
|
+
event_id: event.event_id,
|
|
92
|
+
command: event.command,
|
|
93
|
+
...(event.gate ? { gate: event.gate } : {}),
|
|
94
|
+
...(event.skill ? { skill: event.skill } : {}),
|
|
95
|
+
outcome: event.outcome,
|
|
96
|
+
...(event.failed_criterion ? { failed_criterion: event.failed_criterion } : {}),
|
|
97
|
+
duration_ms: event.duration_ms,
|
|
98
|
+
orly_version: event.orly_version,
|
|
99
|
+
os: event.os,
|
|
100
|
+
arch: event.arch,
|
|
101
|
+
invocation: event.invocation,
|
|
102
|
+
session_id: event.session_id,
|
|
103
|
+
installation_id: event.installation_id,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function compactAcknowledgedPrefix(batch: PendingBatch, now: Date): Promise<void> {
|
|
109
|
+
await withSpoolLock(batch.stateRoot, async () => {
|
|
110
|
+
const current = await readTelemetrySpool(batch.stateRoot, now);
|
|
111
|
+
const acknowledged = new Set(batch.events.map((event) => event.event_id));
|
|
112
|
+
const firstUnacknowledged = current.findIndex((event) => !acknowledged.has(event.event_id));
|
|
113
|
+
const remaining = firstUnacknowledged < 0 ? [] : current.slice(firstUnacknowledged);
|
|
114
|
+
await replaceTelemetrySpool(batch.stateRoot, remaining, now);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function attemptedRecently(stateRoot: string, now: Date): Promise<boolean> {
|
|
119
|
+
const path = lastAttemptPath(stateRoot);
|
|
120
|
+
try {
|
|
121
|
+
const timestamp = Date.parse((await readFile(path, "utf8")).trim());
|
|
122
|
+
if (Number.isFinite(timestamp)) return now.getTime() - timestamp < ATTEMPT_INTERVAL_MILLISECONDS;
|
|
123
|
+
const details = await stat(path);
|
|
124
|
+
return now.getTime() - details.mtimeMs < ATTEMPT_INTERVAL_MILLISECONDS;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function lastAttemptPath(stateRoot: string): string {
|
|
131
|
+
return join(stateRoot, ANALYTICS_DIRECTORY, LAST_ATTEMPT_FILE);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) await syncTelemetry();
|