@girardmedia/bootspring 2.1.3 → 2.2.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/bin/bootspring.js +157 -83
- package/claude-commands/agent.md +34 -0
- package/claude-commands/bs.md +31 -0
- package/claude-commands/build.md +25 -0
- package/claude-commands/skill.md +31 -0
- package/claude-commands/todo.md +25 -0
- package/dist/core/index.d.ts +5814 -0
- package/dist/core.js +5779 -0
- package/dist/index.js +93883 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp-server.js +2298 -0
- package/generators/api-docs.js +3 -3
- package/generators/decisions.js +14 -14
- package/generators/health.js +6 -6
- package/generators/sprint.js +4 -4
- package/generators/templates/build-planning.template.js +2 -2
- package/generators/visual-doc-generator.js +1 -1
- package/package.json +22 -68
- package/cli/agent.js +0 -799
- package/cli/auth.js +0 -896
- package/cli/billing.js +0 -320
- package/cli/build.js +0 -1442
- package/cli/dashboard.js +0 -123
- package/cli/init.js +0 -669
- package/cli/mcp.js +0 -240
- package/cli/orchestrator.js +0 -240
- package/cli/project.js +0 -825
- package/cli/quality.js +0 -281
- package/cli/skill.js +0 -503
- package/cli/switch.js +0 -453
- package/cli/todo.js +0 -629
- package/cli/update.js +0 -132
- package/core/api-client.d.ts +0 -69
- package/core/api-client.js +0 -1482
- package/core/auth.d.ts +0 -98
- package/core/auth.js +0 -737
- package/core/build-orchestrator.js +0 -508
- package/core/build-state.js +0 -612
- package/core/config.d.ts +0 -106
- package/core/config.js +0 -1328
- package/core/context-loader.js +0 -580
- package/core/context.d.ts +0 -61
- package/core/context.js +0 -327
- package/core/entitlements.d.ts +0 -70
- package/core/entitlements.js +0 -322
- package/core/index.d.ts +0 -53
- package/core/index.js +0 -62
- package/core/mcp-config.js +0 -115
- package/core/policies.d.ts +0 -43
- package/core/policies.js +0 -113
- package/core/policy-matrix.js +0 -303
- package/core/project-activity.js +0 -175
- package/core/redaction.d.ts +0 -5
- package/core/redaction.js +0 -63
- package/core/self-update.js +0 -259
- package/core/session.js +0 -353
- package/core/task-extractor.js +0 -1098
- package/core/telemetry.d.ts +0 -55
- package/core/telemetry.js +0 -617
- package/core/tier-enforcement.js +0 -928
- package/core/utils.d.ts +0 -90
- package/core/utils.js +0 -455
- package/core/validation.js +0 -572
- package/mcp/server.d.ts +0 -57
- package/mcp/server.js +0 -264
package/core/telemetry.d.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bootspring Telemetry Types
|
|
3
|
-
* @module core/telemetry
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export interface TelemetryEvent {
|
|
7
|
-
event: string;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
data?: Record<string, unknown>;
|
|
10
|
-
sessionId?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface UploadResult {
|
|
14
|
-
success: boolean;
|
|
15
|
-
uploaded: number;
|
|
16
|
-
errors?: string[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Maximum events to store locally */
|
|
20
|
-
export const MAX_EVENTS_LIMIT: number;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Emit a telemetry event
|
|
24
|
-
* @param event - Event name
|
|
25
|
-
* @param data - Event data
|
|
26
|
-
*/
|
|
27
|
-
export function emit(event: string, data?: Record<string, unknown>): void;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* List stored telemetry events
|
|
31
|
-
* @param limit - Maximum events to return
|
|
32
|
-
* @returns Array of events
|
|
33
|
-
*/
|
|
34
|
-
export function list(limit?: number): TelemetryEvent[];
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Upload telemetry events to server
|
|
38
|
-
* @param options - Upload options
|
|
39
|
-
* @returns Upload result
|
|
40
|
-
*/
|
|
41
|
-
export function upload(options?: {
|
|
42
|
-
batchSize?: number;
|
|
43
|
-
clearOnSuccess?: boolean;
|
|
44
|
-
}): Promise<UploadResult>;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Clear stored telemetry events
|
|
48
|
-
*/
|
|
49
|
-
export function clear(): void;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Get telemetry session ID
|
|
53
|
-
* @returns Current session ID
|
|
54
|
-
*/
|
|
55
|
-
export function getSessionId(): string;
|
package/core/telemetry.js
DELETED
|
@@ -1,617 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bootspring Telemetry
|
|
3
|
-
* Lightweight JSONL event emitter for product instrumentation.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const crypto = require('crypto');
|
|
9
|
-
const { redactErrorMessage, redactSensitiveData } = require('./redaction');
|
|
10
|
-
|
|
11
|
-
const MAX_EVENTS_LIMIT = 10000;
|
|
12
|
-
const ASSISTANTS = ['claude', 'codex', 'gemini'];
|
|
13
|
-
const ASSISTANT_SETUP_EVENT = 'assistant_setup';
|
|
14
|
-
const ASSISTANT_FIRST_SUCCESS_EVENT = 'assistant_first_success';
|
|
15
|
-
const ASSISTANT_RETURN_EVENT = 'assistant_return';
|
|
16
|
-
const BILLING_UPGRADE_STARTED_EVENT = 'billing_upgrade_started';
|
|
17
|
-
const UPGRADE_PROMPT_EVENT = 'premium_prompted';
|
|
18
|
-
const UPGRADE_COMPLETED_EVENT = 'premium_unlocked';
|
|
19
|
-
const ASSISTANT_STATUS_ALLOWLIST = new Set(['installed', 'updated', 'skipped', 'failed', 'created', 'unchanged']);
|
|
20
|
-
const ASSISTANT_SOURCE_ALLOWLIST = new Set(['mcp', 'cli', 'setup', 'dashboard', 'api', 'unknown']);
|
|
21
|
-
|
|
22
|
-
function normalizeAssistantId(value) {
|
|
23
|
-
const normalized = String(value || '').trim().toLowerCase();
|
|
24
|
-
if (!normalized) return null;
|
|
25
|
-
const compact = normalized.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
26
|
-
if (compact === 'claude' || compact === 'claude-code') return 'claude';
|
|
27
|
-
if (compact === 'codex' || compact === 'openai-codex') return 'codex';
|
|
28
|
-
if (compact === 'gemini' || compact === 'gemini-cli') return 'gemini';
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function inferAssistantFromEnvironment(env = process.env) {
|
|
33
|
-
const explicit = normalizeAssistantId(env.BOOTSPRING_ASSISTANT);
|
|
34
|
-
if (explicit) return explicit;
|
|
35
|
-
if (env.CLAUDE_CODE || env.CLAUDECODE) return 'claude';
|
|
36
|
-
if (env.CODEX_SANDBOX || env.CODEX_ENV || env.OPENAI_CODEX) return 'codex';
|
|
37
|
-
if (env.GEMINI_CLI || env.GOOGLE_GEMINI_CLI) return 'gemini';
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalizeCardinalityValue(value, fallback = 'unknown') {
|
|
42
|
-
const normalized = String(value || '')
|
|
43
|
-
.trim()
|
|
44
|
-
.toLowerCase()
|
|
45
|
-
.replace(/[^a-z0-9_-]+/g, '-')
|
|
46
|
-
.replace(/^-+|-+$/g, '');
|
|
47
|
-
return normalized || fallback;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function normalizeAssistantStatus(value) {
|
|
51
|
-
const normalized = normalizeCardinalityValue(value, 'updated');
|
|
52
|
-
return ASSISTANT_STATUS_ALLOWLIST.has(normalized) ? normalized : 'other';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function normalizeAssistantSource(value) {
|
|
56
|
-
const normalized = normalizeCardinalityValue(value, 'unknown');
|
|
57
|
-
return ASSISTANT_SOURCE_ALLOWLIST.has(normalized) ? normalized : 'other';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function getAssistantRecords(assistant, options = {}) {
|
|
61
|
-
const normalized = normalizeAssistantId(assistant);
|
|
62
|
-
if (!normalized) return [];
|
|
63
|
-
return listEvents({
|
|
64
|
-
projectRoot: options.projectRoot
|
|
65
|
-
}).filter(record => normalizeAssistantId(record?.payload?.assistant) === normalized);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function returnWindow(daysSinceFirst) {
|
|
69
|
-
if (daysSinceFirst >= 7) return 'd7_plus';
|
|
70
|
-
if (daysSinceFirst >= 2) return 'd2_6';
|
|
71
|
-
if (daysSinceFirst >= 1) return 'd1';
|
|
72
|
-
return 'd0';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function eventTimeMs(record) {
|
|
76
|
-
const occurredAt = String(record?.payload?.occurredAt || '').trim();
|
|
77
|
-
const source = occurredAt || String(record?.timestamp || '').trim();
|
|
78
|
-
const ts = new Date(source).getTime();
|
|
79
|
-
return Number.isFinite(ts) ? ts : null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function eventTimeIso(record) {
|
|
83
|
-
const occurredAt = String(record?.payload?.occurredAt || '').trim();
|
|
84
|
-
if (occurredAt) return occurredAt;
|
|
85
|
-
const fallback = String(record?.timestamp || '').trim();
|
|
86
|
-
return fallback || null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function getTelemetryDir(projectRoot = process.cwd()) {
|
|
90
|
-
return path.join(projectRoot, '.bootspring', 'telemetry');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function getTelemetryFile(projectRoot = process.cwd()) {
|
|
94
|
-
return path.join(getTelemetryDir(projectRoot), 'events.jsonl');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function ensureTelemetryDir(projectRoot = process.cwd()) {
|
|
98
|
-
const dir = getTelemetryDir(projectRoot);
|
|
99
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
-
return dir;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function emitEvent(event, payload = {}, options = {}) {
|
|
104
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
105
|
-
ensureTelemetryDir(projectRoot);
|
|
106
|
-
const file = getTelemetryFile(projectRoot);
|
|
107
|
-
const eventTime = options.now ? new Date(options.now) : new Date();
|
|
108
|
-
const record = {
|
|
109
|
-
timestamp: eventTime.toISOString(),
|
|
110
|
-
event: String(event || '').trim(),
|
|
111
|
-
payload: redactSensitiveData(payload || {})
|
|
112
|
-
};
|
|
113
|
-
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf-8');
|
|
114
|
-
return record;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function trackAssistantSetup(assistant, payload = {}, options = {}) {
|
|
118
|
-
const normalized = normalizeAssistantId(assistant);
|
|
119
|
-
if (!normalized) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return emitEvent(ASSISTANT_SETUP_EVENT, {
|
|
124
|
-
assistant: normalized,
|
|
125
|
-
status: normalizeAssistantStatus(payload.status)
|
|
126
|
-
}, options);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function trackAssistantUsageSuccess(assistant, payload = {}, options = {}) {
|
|
130
|
-
const normalized = normalizeAssistantId(assistant);
|
|
131
|
-
if (!normalized) {
|
|
132
|
-
return {
|
|
133
|
-
assistant: null,
|
|
134
|
-
firstSuccessTracked: false,
|
|
135
|
-
returnTracked: false,
|
|
136
|
-
daysSinceFirst: null
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
141
|
-
const now = options.now ? new Date(options.now) : new Date();
|
|
142
|
-
const records = getAssistantRecords(normalized, { projectRoot });
|
|
143
|
-
const firstSuccess = records.find(record => record.event === ASSISTANT_FIRST_SUCCESS_EVENT);
|
|
144
|
-
|
|
145
|
-
if (!firstSuccess) {
|
|
146
|
-
emitEvent(ASSISTANT_FIRST_SUCCESS_EVENT, {
|
|
147
|
-
assistant: normalized,
|
|
148
|
-
source: normalizeAssistantSource(payload.source || 'mcp')
|
|
149
|
-
}, { projectRoot, now });
|
|
150
|
-
return {
|
|
151
|
-
assistant: normalized,
|
|
152
|
-
firstSuccessTracked: true,
|
|
153
|
-
returnTracked: false,
|
|
154
|
-
daysSinceFirst: 0
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const firstSuccessAt = eventTimeMs(firstSuccess);
|
|
159
|
-
const daysSinceFirst = Number.isFinite(firstSuccessAt)
|
|
160
|
-
? Math.floor((now.getTime() - firstSuccessAt) / (24 * 60 * 60 * 1000))
|
|
161
|
-
: 0;
|
|
162
|
-
|
|
163
|
-
if (daysSinceFirst >= 1) {
|
|
164
|
-
const window = returnWindow(daysSinceFirst);
|
|
165
|
-
const alreadyTrackedWindow = records.some(record => {
|
|
166
|
-
if (record.event !== ASSISTANT_RETURN_EVENT) return false;
|
|
167
|
-
return String(record?.payload?.window || '') === window;
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
if (!alreadyTrackedWindow) {
|
|
171
|
-
emitEvent(ASSISTANT_RETURN_EVENT, {
|
|
172
|
-
assistant: normalized,
|
|
173
|
-
source: normalizeAssistantSource(payload.source || 'mcp'),
|
|
174
|
-
window
|
|
175
|
-
}, { projectRoot, now });
|
|
176
|
-
return {
|
|
177
|
-
assistant: normalized,
|
|
178
|
-
firstSuccessTracked: false,
|
|
179
|
-
returnTracked: true,
|
|
180
|
-
daysSinceFirst
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
assistant: normalized,
|
|
187
|
-
firstSuccessTracked: false,
|
|
188
|
-
returnTracked: false,
|
|
189
|
-
daysSinceFirst
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function dayDelta(fromIso, toIso) {
|
|
194
|
-
const from = new Date(String(fromIso || '')).getTime();
|
|
195
|
-
const to = new Date(String(toIso || '')).getTime();
|
|
196
|
-
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
return Math.floor((to - from) / (24 * 60 * 60 * 1000));
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function summarizeAssistantFunnel(records, assistant) {
|
|
203
|
-
const filtered = records.filter(record => normalizeAssistantId(record?.payload?.assistant) === assistant);
|
|
204
|
-
const setupEvent = filtered.find(record => record.event === ASSISTANT_SETUP_EVENT);
|
|
205
|
-
const firstSuccessEvent = filtered.find(record => record.event === ASSISTANT_FIRST_SUCCESS_EVENT);
|
|
206
|
-
const returns = filtered.filter(record => record.event === ASSISTANT_RETURN_EVENT);
|
|
207
|
-
|
|
208
|
-
const d1 = firstSuccessEvent
|
|
209
|
-
? returns.some(record => {
|
|
210
|
-
const delta = dayDelta(eventTimeIso(firstSuccessEvent), eventTimeIso(record));
|
|
211
|
-
return delta !== null && delta >= 1;
|
|
212
|
-
})
|
|
213
|
-
: false;
|
|
214
|
-
const d7 = firstSuccessEvent
|
|
215
|
-
? returns.some(record => {
|
|
216
|
-
const delta = dayDelta(eventTimeIso(firstSuccessEvent), eventTimeIso(record));
|
|
217
|
-
return delta !== null && delta >= 7;
|
|
218
|
-
})
|
|
219
|
-
: false;
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
setup: !!setupEvent,
|
|
223
|
-
firstSuccess: !!firstSuccessEvent,
|
|
224
|
-
returned: returns.length > 0,
|
|
225
|
-
d1,
|
|
226
|
-
d7,
|
|
227
|
-
setupAt: eventTimeIso(setupEvent),
|
|
228
|
-
firstSuccessAt: eventTimeIso(firstSuccessEvent),
|
|
229
|
-
lastReturnAt: returns.length > 0 ? eventTimeIso(returns[returns.length - 1]) : null
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function getAssistantActivationFunnel(options = {}) {
|
|
234
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
235
|
-
const assistantFilter = normalizeAssistantId(options.assistant);
|
|
236
|
-
const records = listEvents({
|
|
237
|
-
projectRoot,
|
|
238
|
-
from: options.from,
|
|
239
|
-
to: options.to
|
|
240
|
-
});
|
|
241
|
-
const assistantIds = assistantFilter ? [assistantFilter] : ASSISTANTS;
|
|
242
|
-
|
|
243
|
-
const assistants = {};
|
|
244
|
-
for (const assistant of assistantIds) {
|
|
245
|
-
assistants[assistant] = summarizeAssistantFunnel(records, assistant);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const totals = Object.values(assistants).reduce((acc, entry) => {
|
|
249
|
-
if (entry.setup) acc.setup += 1;
|
|
250
|
-
if (entry.firstSuccess) acc.firstSuccess += 1;
|
|
251
|
-
if (entry.returned) acc.return += 1;
|
|
252
|
-
if (entry.d1) acc.d1 += 1;
|
|
253
|
-
if (entry.d7) acc.d7 += 1;
|
|
254
|
-
return acc;
|
|
255
|
-
}, { setup: 0, firstSuccess: 0, return: 0, d1: 0, d7: 0 });
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
generatedAt: new Date().toISOString(),
|
|
259
|
-
query: {
|
|
260
|
-
assistant: assistantFilter,
|
|
261
|
-
from: options.from || null,
|
|
262
|
-
to: options.to || null
|
|
263
|
-
},
|
|
264
|
-
totals,
|
|
265
|
-
assistants
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function safeRate(numerator, denominator) {
|
|
270
|
-
if (!denominator) return 0;
|
|
271
|
-
return Number((numerator / denominator).toFixed(4));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function normalizeDimension(value, fallback) {
|
|
275
|
-
const normalized = String(value || '')
|
|
276
|
-
.trim()
|
|
277
|
-
.toLowerCase()
|
|
278
|
-
.replace(/[^a-z0-9_-]+/g, '-')
|
|
279
|
-
.replace(/^-+|-+$/g, '');
|
|
280
|
-
return normalized || fallback;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function getUpgradeEventContext(payload = {}) {
|
|
284
|
-
const feature = payload.feature || payload.skillId || payload.workflow || payload.agent || 'unknown';
|
|
285
|
-
return {
|
|
286
|
-
capability: normalizeDimension(payload.capability, 'unknown'),
|
|
287
|
-
featureType: normalizeDimension(payload.featureType, 'general'),
|
|
288
|
-
feature: normalizeDimension(feature, 'unknown'),
|
|
289
|
-
placement: normalizeDimension(payload.placement, 'inline'),
|
|
290
|
-
variant: normalizeDimension(payload.variant, 'control')
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Summarize upgrade conversion funnel (prompt shown -> upgrade started -> premium unlocked).
|
|
296
|
-
* @param {{ projectRoot?: string }} options
|
|
297
|
-
* @returns {{ generatedAt: string, totals: { prompted: number, started: number, completed: number, converted: number, startRate: number, conversionRate: number }, capabilities: Record<string, object> }}
|
|
298
|
-
*/
|
|
299
|
-
function getUpgradeConversionFunnel(options = {}) {
|
|
300
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
301
|
-
const records = listEvents({ projectRoot });
|
|
302
|
-
const capabilities = {};
|
|
303
|
-
|
|
304
|
-
for (const record of records) {
|
|
305
|
-
if (record.event !== UPGRADE_PROMPT_EVENT
|
|
306
|
-
&& record.event !== BILLING_UPGRADE_STARTED_EVENT
|
|
307
|
-
&& record.event !== UPGRADE_COMPLETED_EVENT) {
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const context = getUpgradeEventContext(record.payload);
|
|
312
|
-
const key = `${context.capability}:${context.featureType}:${context.feature}`;
|
|
313
|
-
if (!capabilities[key]) {
|
|
314
|
-
capabilities[key] = {
|
|
315
|
-
capability: context.capability,
|
|
316
|
-
featureType: context.featureType,
|
|
317
|
-
feature: context.feature,
|
|
318
|
-
prompted: 0,
|
|
319
|
-
started: 0,
|
|
320
|
-
completed: 0,
|
|
321
|
-
converted: 0,
|
|
322
|
-
conversionRate: 0,
|
|
323
|
-
placements: {},
|
|
324
|
-
variants: {}
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const bucket = capabilities[key];
|
|
329
|
-
if (!bucket) continue;
|
|
330
|
-
|
|
331
|
-
if (record.event === UPGRADE_PROMPT_EVENT) {
|
|
332
|
-
bucket.prompted += 1;
|
|
333
|
-
bucket.placements[context.placement] = (bucket.placements[context.placement] || 0) + 1;
|
|
334
|
-
bucket.variants[context.variant] = (bucket.variants[context.variant] || 0) + 1;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (record.event === BILLING_UPGRADE_STARTED_EVENT) {
|
|
339
|
-
bucket.started += 1;
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
bucket.completed += 1;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const totals = Object.values(capabilities).reduce((acc, bucket) => {
|
|
347
|
-
bucket.converted = Math.min(bucket.prompted, bucket.completed);
|
|
348
|
-
bucket.conversionRate = safeRate(bucket.converted, bucket.prompted);
|
|
349
|
-
|
|
350
|
-
acc.prompted += bucket.prompted;
|
|
351
|
-
acc.started += bucket.started;
|
|
352
|
-
acc.completed += bucket.completed;
|
|
353
|
-
acc.converted += bucket.converted;
|
|
354
|
-
return acc;
|
|
355
|
-
}, {
|
|
356
|
-
prompted: 0,
|
|
357
|
-
started: 0,
|
|
358
|
-
completed: 0,
|
|
359
|
-
converted: 0
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
generatedAt: new Date().toISOString(),
|
|
364
|
-
totals: {
|
|
365
|
-
...totals,
|
|
366
|
-
startRate: safeRate(totals.started, totals.prompted),
|
|
367
|
-
conversionRate: safeRate(totals.converted, totals.prompted)
|
|
368
|
-
},
|
|
369
|
-
capabilities
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function parseEventLine(line) {
|
|
374
|
-
try {
|
|
375
|
-
return JSON.parse(line);
|
|
376
|
-
} catch {
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function listEvents(options = {}) {
|
|
382
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
383
|
-
const file = getTelemetryFile(projectRoot);
|
|
384
|
-
if (!fs.existsSync(file)) return [];
|
|
385
|
-
|
|
386
|
-
const eventFilter = String(options.event || '').trim();
|
|
387
|
-
const from = options.from ? new Date(options.from).getTime() : null;
|
|
388
|
-
const to = options.to ? new Date(options.to).getTime() : null;
|
|
389
|
-
const limit = Number(options.limit);
|
|
390
|
-
|
|
391
|
-
const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
|
|
392
|
-
let records = lines
|
|
393
|
-
.map(parseEventLine)
|
|
394
|
-
.filter(Boolean)
|
|
395
|
-
.filter(record => {
|
|
396
|
-
if (eventFilter && record.event !== eventFilter) return false;
|
|
397
|
-
const ts = new Date(record.timestamp).getTime();
|
|
398
|
-
if (Number.isFinite(from) && ts < from) return false;
|
|
399
|
-
if (Number.isFinite(to) && ts > to) return false;
|
|
400
|
-
return true;
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
const effectiveLimit = Number.isFinite(limit) && limit > 0
|
|
404
|
-
? Math.min(limit, MAX_EVENTS_LIMIT)
|
|
405
|
-
: MAX_EVENTS_LIMIT;
|
|
406
|
-
records = records.slice(-effectiveLimit);
|
|
407
|
-
return records;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function clearEvents(options = {}) {
|
|
411
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
412
|
-
const file = getTelemetryFile(projectRoot);
|
|
413
|
-
const records = listEvents({ projectRoot });
|
|
414
|
-
if (fs.existsSync(file)) {
|
|
415
|
-
fs.writeFileSync(file, '', 'utf-8');
|
|
416
|
-
}
|
|
417
|
-
return {
|
|
418
|
-
cleared: records.length,
|
|
419
|
-
file
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function getStatus(options = {}) {
|
|
424
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
425
|
-
const file = getTelemetryFile(projectRoot);
|
|
426
|
-
const records = listEvents({ projectRoot });
|
|
427
|
-
return {
|
|
428
|
-
file,
|
|
429
|
-
exists: fs.existsSync(file),
|
|
430
|
-
count: records.length,
|
|
431
|
-
lastEventAt: records.length > 0 ? records[records.length - 1].timestamp : null
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function sleep(ms) {
|
|
436
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function chunkArray(items, size) {
|
|
440
|
-
const chunks = [];
|
|
441
|
-
for (let i = 0; i < items.length; i += size) {
|
|
442
|
-
chunks.push(items.slice(i, i + size));
|
|
443
|
-
}
|
|
444
|
-
return chunks;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
async function postBatchWithRetry(endpoint, body, headers, options = {}) {
|
|
448
|
-
const maxRetries = Number(options.maxRetries);
|
|
449
|
-
const retryDelayMs = Number(options.retryDelayMs);
|
|
450
|
-
const retries = Number.isFinite(maxRetries) && maxRetries >= 0 ? maxRetries : 2;
|
|
451
|
-
const delayBase = Number.isFinite(retryDelayMs) && retryDelayMs > 0 ? retryDelayMs : 300;
|
|
452
|
-
|
|
453
|
-
let attempt = 0;
|
|
454
|
-
while (true) {
|
|
455
|
-
try {
|
|
456
|
-
const response = await fetch(endpoint, {
|
|
457
|
-
method: 'POST',
|
|
458
|
-
headers,
|
|
459
|
-
body: JSON.stringify(body)
|
|
460
|
-
});
|
|
461
|
-
if (!response.ok) {
|
|
462
|
-
throw new Error(`HTTP ${response.status}`);
|
|
463
|
-
}
|
|
464
|
-
return { success: true, attempts: attempt + 1 };
|
|
465
|
-
} catch (error) {
|
|
466
|
-
if (attempt >= retries) {
|
|
467
|
-
return {
|
|
468
|
-
success: false,
|
|
469
|
-
attempts: attempt + 1,
|
|
470
|
-
error: redactErrorMessage(error)
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
attempt += 1;
|
|
474
|
-
const delay = delayBase * (2 ** (attempt - 1));
|
|
475
|
-
await sleep(delay);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
async function uploadEvents(options = {}) {
|
|
481
|
-
const projectRoot = options.projectRoot || process.cwd();
|
|
482
|
-
const API_BASE = process.env.BOOTSPRING_API_URL || 'https://www.bootspring.com';
|
|
483
|
-
const defaultEndpoint = `${API_BASE}/api/v1/events/batch`;
|
|
484
|
-
const endpoint = options.endpoint || process.env.BOOTSPRING_TELEMETRY_ENDPOINT || defaultEndpoint;
|
|
485
|
-
|
|
486
|
-
const token = options.token || process.env.BOOTSPRING_TELEMETRY_TOKEN;
|
|
487
|
-
const event = options.event;
|
|
488
|
-
const limit = Number(options.limit) || undefined;
|
|
489
|
-
const batchSizeOption = Number(options.batchSize || process.env.BOOTSPRING_TELEMETRY_BATCH_SIZE);
|
|
490
|
-
const batchSize = Number.isFinite(batchSizeOption) && batchSizeOption > 0 ? batchSizeOption : 100;
|
|
491
|
-
const clearOnSuccess = options.clearOnSuccess === true;
|
|
492
|
-
const records = listEvents({ projectRoot, event, limit });
|
|
493
|
-
|
|
494
|
-
if (records.length === 0) {
|
|
495
|
-
return {
|
|
496
|
-
uploaded: 0,
|
|
497
|
-
remaining: 0,
|
|
498
|
-
endpoint
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// Try to get API key and project context from auth/session modules
|
|
503
|
-
let apiKey = null;
|
|
504
|
-
let projectId = null;
|
|
505
|
-
try {
|
|
506
|
-
const auth = require('./auth');
|
|
507
|
-
const session = require('./session');
|
|
508
|
-
apiKey = auth.getApiKey();
|
|
509
|
-
const project = session.getEffectiveProject();
|
|
510
|
-
projectId = project?.id || null;
|
|
511
|
-
} catch {
|
|
512
|
-
// Modules not available, continue without
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const headers = {
|
|
516
|
-
'content-type': 'application/json',
|
|
517
|
-
accept: 'application/json',
|
|
518
|
-
'user-agent': `bootspring-cli/${require('../package.json').version}`
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
// Use API key if available, otherwise fall back to token
|
|
522
|
-
if (apiKey) {
|
|
523
|
-
headers['x-api-key'] = apiKey;
|
|
524
|
-
} else if (token) {
|
|
525
|
-
headers.authorization = `Bearer ${token}`;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Add project context if available
|
|
529
|
-
if (projectId) {
|
|
530
|
-
headers['x-project-id'] = projectId;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const batches = chunkArray(records, batchSize);
|
|
534
|
-
let uploaded = 0;
|
|
535
|
-
let totalAttempts = 0;
|
|
536
|
-
const failedBatches = [];
|
|
537
|
-
|
|
538
|
-
for (let i = 0; i < batches.length; i++) {
|
|
539
|
-
const events = batches[i];
|
|
540
|
-
const batchId = crypto.createHash('sha1')
|
|
541
|
-
.update(JSON.stringify(events))
|
|
542
|
-
.digest('hex');
|
|
543
|
-
const result = await postBatchWithRetry(
|
|
544
|
-
endpoint,
|
|
545
|
-
{
|
|
546
|
-
source: 'bootspring',
|
|
547
|
-
batch: {
|
|
548
|
-
index: i,
|
|
549
|
-
total: batches.length,
|
|
550
|
-
id: batchId
|
|
551
|
-
},
|
|
552
|
-
events
|
|
553
|
-
},
|
|
554
|
-
{
|
|
555
|
-
...headers,
|
|
556
|
-
'x-bootspring-batch-id': batchId
|
|
557
|
-
},
|
|
558
|
-
options
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
totalAttempts += result.attempts || 1;
|
|
562
|
-
if (!result.success) {
|
|
563
|
-
failedBatches.push({
|
|
564
|
-
index: i,
|
|
565
|
-
count: events.length,
|
|
566
|
-
error: redactErrorMessage(result.error || 'upload_failed')
|
|
567
|
-
});
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
uploaded += events.length;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
if (failedBatches.length > 0) {
|
|
574
|
-
throw new Error(`Telemetry upload failed for ${failedBatches.length}/${batches.length} batches`);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (clearOnSuccess) {
|
|
578
|
-
clearEvents({ projectRoot });
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const remaining = clearOnSuccess ? 0 : listEvents({ projectRoot }).length;
|
|
582
|
-
return {
|
|
583
|
-
uploaded,
|
|
584
|
-
attempted: records.length,
|
|
585
|
-
batches: batches.length,
|
|
586
|
-
attempts: totalAttempts,
|
|
587
|
-
remaining,
|
|
588
|
-
endpoint,
|
|
589
|
-
failedBatches
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
module.exports = {
|
|
594
|
-
MAX_EVENTS_LIMIT,
|
|
595
|
-
ASSISTANTS,
|
|
596
|
-
ASSISTANT_SETUP_EVENT,
|
|
597
|
-
ASSISTANT_FIRST_SUCCESS_EVENT,
|
|
598
|
-
ASSISTANT_RETURN_EVENT,
|
|
599
|
-
BILLING_UPGRADE_STARTED_EVENT,
|
|
600
|
-
UPGRADE_PROMPT_EVENT,
|
|
601
|
-
UPGRADE_COMPLETED_EVENT,
|
|
602
|
-
getTelemetryDir,
|
|
603
|
-
getTelemetryFile,
|
|
604
|
-
ensureTelemetryDir,
|
|
605
|
-
emitEvent,
|
|
606
|
-
track: emitEvent, // Alias for compatibility
|
|
607
|
-
normalizeAssistantId,
|
|
608
|
-
inferAssistantFromEnvironment,
|
|
609
|
-
trackAssistantSetup,
|
|
610
|
-
trackAssistantUsageSuccess,
|
|
611
|
-
getAssistantActivationFunnel,
|
|
612
|
-
getUpgradeConversionFunnel,
|
|
613
|
-
listEvents,
|
|
614
|
-
clearEvents,
|
|
615
|
-
getStatus,
|
|
616
|
-
uploadEvents
|
|
617
|
-
};
|