@opendatalabs/connect 0.12.2 → 0.13.1-canary.5f3c748

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.
@@ -3,13 +3,14 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { getTelemetryOutboxDir, readCliConfig, updateCliConfig, } from "../core/index.js";
6
- const TELEMETRY_ENDPOINT = "https://telemetry.opendatalabs.com/v1/cli/events";
6
+ import { TELEMETRY_ENDPOINT, TELEMETRY_EVENT_VERSION, TELEMETRY_PRODUCER_NAME, } from "./telemetry-contract.js";
7
+ // ── Config ──────────────────────────────────────────────────────────────────
7
8
  const MAX_EVENTS_PER_BATCH = 100;
8
9
  const MAX_BATCH_BYTES = 64 * 1024;
9
10
  const MAX_FILES_PER_FLUSH = 10;
10
11
  const FLUSH_TIMEOUT_MS = 1500;
11
- const EVENT_VERSION = 1;
12
12
  let activeSession = null;
13
+ // ── Helpers ─────────────────────────────────────────────────────────────────
13
14
  function randomId(prefix) {
14
15
  return `${prefix}_${crypto.randomUUID()}`;
15
16
  }
@@ -19,177 +20,74 @@ function nowIso() {
19
20
  function getEndpoint() {
20
21
  return process.env.VANA_TELEMETRY_URL?.trim() || TELEMETRY_ENDPOINT;
21
22
  }
22
- function getInteractive(options) {
23
- return Boolean(process.stdin.isTTY && process.stdout.isTTY && !options.noInput);
23
+ function detectOs() {
24
+ const platform = process.platform;
25
+ if (platform === "linux")
26
+ return "linux";
27
+ if (platform === "darwin")
28
+ return "macos";
29
+ if (platform === "win32")
30
+ return "windows";
31
+ return "linux"; // fallback
24
32
  }
25
- function sanitizeMetadata(metadata) {
26
- if (!metadata) {
27
- return null;
28
- }
29
- return Object.fromEntries(Object.entries(metadata).slice(0, 16));
33
+ function detectArch() {
34
+ const arch = os.arch();
35
+ if (arch === "arm64")
36
+ return "arm64";
37
+ return "x86_64";
30
38
  }
31
- function countScopeResults(scopeResults) {
32
- return {
33
- storedScopeCount: scopeResults?.filter((item) => item.status === "stored").length ?? null,
34
- failedScopeCount: scopeResults?.filter((item) => item.status === "failed").length ?? null,
35
- };
39
+ function makeHostPlatform(osName, arch) {
40
+ return `${osName}-${arch}`;
36
41
  }
37
- function classifyError(reason) {
38
- const value = (reason ?? "").toLowerCase();
39
- if (!value)
40
- return null;
41
- if (value.includes("setup_declined"))
42
- return "setup_declined";
43
- if (value.includes("setup_required"))
44
- return "setup_required";
45
- if (value.includes("source_required"))
46
- return "source_required";
47
- if (value.includes("prompt_cancelled") || value.includes("cancelled"))
48
- return "prompt_cancelled";
49
- if (value.includes("needs_input") ||
50
- value.includes("needs input") ||
51
- value.includes("input required") ||
52
- value.includes("manual step"))
53
- return "needs_input";
54
- if (value.includes("auth expired"))
55
- return "auth_expired";
56
- if (value.includes("auth"))
57
- return "auth_failed";
58
- if (value.includes("legacy"))
59
- return "legacy_auth";
60
- if (value.includes("personal_server_unavailable") ||
61
- value.includes("personal server unavailable"))
42
+ // Classify a free-form CLI error/reason string into a canonical error class.
43
+ // The CLI's own classification produced richer values (e.g. setup_required,
44
+ // legacy_auth); we collapse these into the canonical whitelist here.
45
+ function classifyCanonicalError(value) {
46
+ const normalized = (value ?? "").toLowerCase();
47
+ if (!normalized)
48
+ return "unknown";
49
+ if (normalized.includes("personal_server_unavailable") ||
50
+ normalized.includes("personal server")) {
62
51
  return "personal_server_unavailable";
63
- if (value.includes("timeout") || value.includes("timed out"))
52
+ }
53
+ if (normalized.includes("auth") || normalized.includes("legacy")) {
54
+ return "auth_failed";
55
+ }
56
+ if (normalized.includes("timeout") || normalized.includes("timed out")) {
64
57
  return "timeout";
65
- if (value.includes("network"))
58
+ }
59
+ if (normalized.includes("network")) {
66
60
  return "network_error";
67
- if (value.includes("ingest"))
68
- return "ingest_failed";
69
- if (value.includes("connector"))
70
- return "connector_unavailable";
71
- if (value.includes("runtime"))
61
+ }
62
+ if (normalized.includes("runtime") ||
63
+ normalized.includes("connector") ||
64
+ normalized.includes("unexpected") ||
65
+ normalized.includes("ingest_failed") ||
66
+ normalized.includes("setup_required") ||
67
+ normalized.includes("invalid_connector")) {
72
68
  return "runtime_error";
73
- return "unknown";
74
- }
75
- function classifyOutcomeStatus(outcome) {
76
- switch (outcome) {
77
- case "needs_input":
78
- return "needs_input";
79
- case "auth_failed":
80
- return "auth_failed";
81
- case "legacy_auth":
82
- return "legacy_auth";
83
- case "personal_server_unavailable":
84
- return "personal_server_unavailable";
85
- case "ingest_failed":
86
- return "ingest_failed";
87
- case "connector_unavailable":
88
- return "connector_unavailable";
89
- case "runtime_error":
90
- return "runtime_error";
91
- case "setup_required":
92
- return "setup_required";
93
- case "setup_declined":
94
- return "setup_declined";
95
- case "source_required":
96
- return "source_required";
97
- case "prompt_cancelled":
98
- return "prompt_cancelled";
99
- case "invalid_connector":
100
- return "invalid_connector";
101
- case "unexpected_internal_error":
102
- return "unexpected_internal_error";
103
- default:
104
- return null;
105
69
  }
70
+ return "unknown";
106
71
  }
107
- function resolveErrorClass(...candidates) {
108
- const preferred = candidates.find((candidate) => Boolean(candidate && candidate !== "unknown"));
109
- if (preferred) {
110
- return preferred;
111
- }
112
- return (candidates.find((candidate) => Boolean(candidate)) ??
113
- null);
114
- }
115
- function mapCliEventToTelemetry(event) {
116
- switch (event.type) {
117
- case "setup-check":
118
- return {
119
- eventName: "runtime_check_completed",
120
- patch: {
121
- metadata: sanitizeMetadata({ runtime: event.runtime ?? null }),
122
- },
123
- };
124
- case "setup-complete":
125
- return {
126
- eventName: "runtime_install_completed",
127
- patch: {
128
- metadata: sanitizeMetadata({ runtime: event.runtime ?? null }),
129
- },
130
- };
131
- case "connector-resolved":
132
- return { eventName: "connector_resolved" };
133
- case "needs-input":
134
- return {
135
- eventName: "input_required",
136
- patch: {
137
- errorClass: "needs_input",
138
- metadata: sanitizeMetadata({ fieldCount: event.fields?.length ?? 0 }),
139
- },
140
- };
141
- case "legacy-auth":
142
- return {
143
- eventName: "legacy_auth_required",
144
- patch: { errorClass: "legacy_auth" },
145
- };
146
- case "collection-complete":
147
- return { eventName: "collection_completed" };
148
- case "runtime-error":
149
- return {
150
- eventName: "collection_failed",
151
- patch: { errorClass: classifyError(event.message) ?? "runtime_error" },
152
- };
153
- case "ingest-started":
154
- return { eventName: "ingest_started" };
155
- case "ingest-complete":
156
- return {
157
- eventName: "ingest_completed",
158
- patch: countScopeResults(event.scopeResults),
159
- };
160
- case "ingest-partial":
161
- return {
162
- eventName: "ingest_partial",
163
- patch: {
164
- ...countScopeResults(event.scopeResults),
165
- errorClass: "ingest_failed",
166
- },
167
- };
168
- case "ingest-failed":
169
- return {
170
- eventName: "ingest_failed",
171
- patch: {
172
- ...countScopeResults(event.scopeResults),
173
- errorClass: "ingest_failed",
174
- },
175
- };
176
- case "ingest-skipped":
177
- return {
178
- eventName: "ingest_skipped",
179
- patch: {
180
- errorClass: classifyError(event.reason) ?? null,
181
- metadata: sanitizeMetadata({ reason: event.reason ?? null }),
182
- },
183
- };
184
- case "outcome":
185
- case "progress-update":
186
- case "status-update":
187
- case "headed-required":
188
- case "jpeg":
189
- return null;
190
- }
191
- return null;
72
+ // Map a CLI interaction indicator to a canonical InteractionKind.
73
+ function mapInteractionKind(value) {
74
+ const normalized = (value ?? "").toLowerCase();
75
+ if (!normalized)
76
+ return undefined;
77
+ if (normalized.includes("otp"))
78
+ return "otp";
79
+ if (normalized.includes("captcha"))
80
+ return "captcha";
81
+ if (normalized.includes("login") || normalized.includes("credential"))
82
+ return "login";
83
+ return "manual_action";
192
84
  }
85
+ // ── Outbox (file-backed) ────────────────────────────────────────────────────
86
+ //
87
+ // Events are batched into TelemetryBatch envelopes, written to individual
88
+ // JSON files in getTelemetryOutboxDir(), and flushed on command exit and on
89
+ // subsequent runs (retry across restarts). This survives crashes — the
90
+ // pattern is worth preserving for the desktop app too.
193
91
  async function ensureOutboxDir() {
194
92
  await fs.mkdir(getTelemetryOutboxDir(), { recursive: true });
195
93
  }
@@ -272,52 +170,64 @@ async function resolveTelemetryState(localOnly = false) {
272
170
  queuedBatches,
273
171
  };
274
172
  }
173
+ // ── Event factory ───────────────────────────────────────────────────────────
174
+ //
175
+ // Builds canonical TelemetryEvent values with identity + time + attribution +
176
+ // context auto-populated. The caller supplies correlation + kind.
275
177
  function createEventFactory(context, state) {
276
- const runId = randomId("run");
277
- const base = {
278
- installId: state.installId,
279
- runId,
280
- command: context.command,
281
- subcommand: context.subcommand ?? null,
282
- source: context.source ?? null,
283
- connectorVersion: null,
284
- authMode: null,
285
- platform: `${process.platform}-${os.arch()}`,
286
- os: process.platform,
287
- arch: os.arch(),
288
- cliVersion: context.cliVersion,
289
- channel: context.channel,
290
- installMethod: context.installMethod,
291
- ci: Boolean(process.env.CI),
292
- agent: Boolean(process.env.AGENT),
293
- interactive: getInteractive(context.options),
178
+ const hostRunId = randomId("host");
179
+ const osName = detectOs();
180
+ const arch = detectArch();
181
+ const hostPlatform = makeHostPlatform(osName, arch);
182
+ const baseContext = {
183
+ hostPlatform,
184
+ os: osName,
185
+ arch,
186
+ producerVersion: context.cliVersion,
187
+ };
188
+ return {
189
+ hostRunId,
190
+ build(args) {
191
+ return {
192
+ identity: {
193
+ eventId: randomId("evt"),
194
+ eventVersion: TELEMETRY_EVENT_VERSION,
195
+ },
196
+ time: {
197
+ occurredAt: nowIso(),
198
+ ...(args.durationMs !== undefined
199
+ ? { durationMs: args.durationMs }
200
+ : {}),
201
+ },
202
+ attribution: {
203
+ producer: TELEMETRY_PRODUCER_NAME,
204
+ installId: state.installId,
205
+ },
206
+ context: {
207
+ ...baseContext,
208
+ ...(args.connectorVersion
209
+ ? { connectorVersion: args.connectorVersion }
210
+ : {}),
211
+ ...(args.authMode ? { authMode: args.authMode } : {}),
212
+ },
213
+ correlation: args.correlation,
214
+ kind: args.kind,
215
+ ...(args.debug ? { debug: args.debug } : {}),
216
+ ...(args.extensions ? { extensions: args.extensions } : {}),
217
+ };
218
+ },
294
219
  };
295
- return (eventName, patch = {}) => ({
296
- eventId: randomId("evt"),
297
- eventVersion: EVENT_VERSION,
298
- timestamp: nowIso(),
299
- eventName,
300
- ...base,
301
- ...patch,
302
- outcome: patch.outcome ?? null,
303
- errorClass: patch.errorClass ?? null,
304
- durationMs: patch.durationMs ?? null,
305
- storedScopeCount: patch.storedScopeCount ?? null,
306
- failedScopeCount: patch.failedScopeCount ?? null,
307
- metadata: sanitizeMetadata(patch.metadata),
308
- });
309
220
  }
310
- function splitIntoEnvelopes(events, cliVersion) {
221
+ // ── Batch serialization ─────────────────────────────────────────────────────
222
+ function splitIntoEnvelopes(events) {
311
223
  const envelopes = [];
312
224
  let current = [];
313
225
  const flushCurrent = () => {
314
- if (current.length === 0) {
226
+ if (current.length === 0)
315
227
  return;
316
- }
317
228
  envelopes.push({
318
229
  batchId: randomId("batch"),
319
230
  sentAt: nowIso(),
320
- client: { name: "vana-cli", version: cliVersion },
321
231
  events: current,
322
232
  });
323
233
  current = [];
@@ -327,7 +237,6 @@ function splitIntoEnvelopes(events, cliVersion) {
327
237
  const candidate = {
328
238
  batchId: "batch_candidate",
329
239
  sentAt: nowIso(),
330
- client: { name: "vana-cli", version: cliVersion },
331
240
  events: current,
332
241
  };
333
242
  const tooManyEvents = current.length > MAX_EVENTS_PER_BATCH;
@@ -335,25 +244,23 @@ function splitIntoEnvelopes(events, cliVersion) {
335
244
  if (tooManyEvents || tooLarge) {
336
245
  const overflow = current.pop();
337
246
  flushCurrent();
338
- if (overflow) {
247
+ if (overflow)
339
248
  current.push(overflow);
340
- }
341
249
  }
342
250
  }
343
251
  flushCurrent();
344
252
  return envelopes;
345
253
  }
346
- async function writeEnvelope(envelope, runId) {
254
+ async function writeEnvelope(envelope, hostRunId) {
347
255
  await ensureOutboxDir();
348
- const filename = `${Date.now()}-${process.pid}-${runId}-${crypto.randomUUID()}.json`;
256
+ const filename = `${Date.now()}-${process.pid}-${hostRunId}-${crypto.randomUUID()}.json`;
349
257
  const outboxPath = path.join(getTelemetryOutboxDir(), filename);
350
258
  await fs.writeFile(outboxPath, `${JSON.stringify(envelope)}\n`, "utf8");
351
259
  }
352
260
  export async function flushTelemetryOutbox() {
353
261
  const state = await resolveTelemetryState();
354
- if (!state.enabled || state.mode !== "normal") {
262
+ if (!state.enabled || state.mode !== "normal")
355
263
  return;
356
- }
357
264
  const files = (await listOutboxFiles()).slice(0, MAX_FILES_PER_FLUSH);
358
265
  for (const filePath of files) {
359
266
  let contents;
@@ -368,7 +275,7 @@ export async function flushTelemetryOutbox() {
368
275
  method: "POST",
369
276
  headers: {
370
277
  "Content-Type": "application/json",
371
- "User-Agent": `vana-cli/${JSON.parse(contents).client?.version ?? "unknown"}`,
278
+ "User-Agent": `vana-cli/unknown`,
372
279
  },
373
280
  body: contents,
374
281
  signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS),
@@ -382,6 +289,7 @@ export async function flushTelemetryOutbox() {
382
289
  }
383
290
  }
384
291
  }
292
+ // ── Public helpers ──────────────────────────────────────────────────────────
385
293
  export async function getTelemetryStatus() {
386
294
  const state = await resolveTelemetryState();
387
295
  return {
@@ -402,93 +310,312 @@ export function setActiveTelemetrySession(session) {
402
310
  export function getActiveTelemetrySession() {
403
311
  return activeSession;
404
312
  }
405
- export function trackActiveTelemetryEvent(eventName, patch) {
406
- activeSession?.trackCustomEvent(eventName, patch);
407
- }
313
+ // ── Session ─────────────────────────────────────────────────────────────────
408
314
  export async function createCliTelemetrySession(context) {
409
315
  const state = await resolveTelemetryState(Boolean(context.localOnly));
410
- const buildEvent = createEventFactory(context, state);
316
+ const factory = createEventFactory(context, state);
411
317
  const startedAt = Date.now();
412
318
  const events = [];
413
- let latestOutcome = null;
414
- let latestErrorClass = null;
319
+ // Per-session state. A single CLI invocation is ONE host run, with zero
320
+ // or more collection runs and sync attempts nested within.
321
+ const hostRunId = factory.hostRunId;
322
+ let collectionRunId = null;
323
+ let collectionSource = context.source ?? null;
324
+ let connectorVersion;
325
+ let authMode;
326
+ let syncRunId = null;
327
+ let latestOutcomeRaw = null;
415
328
  let persisted = false;
416
- const push = (eventName, patch = {}) => {
417
- if (!state.enabled && state.mode !== "debug") {
329
+ // Host-level extensions captured from the command context. These are
330
+ // producer-specific details that don't belong in the canonical Kind.
331
+ const hostExtensions = {
332
+ command: context.command,
333
+ subcommand: context.subcommand ?? null,
334
+ channel: context.channel,
335
+ installMethod: context.installMethod,
336
+ isCi: Boolean(process.env.CI),
337
+ isAgent: Boolean(process.env.AGENT),
338
+ isInteractive: Boolean(process.stdin.isTTY && process.stdout.isTTY && !context.options.noInput),
339
+ };
340
+ const push = (event) => {
341
+ if (!state.enabled && state.mode !== "debug")
418
342
  return;
419
- }
420
- const next = buildEvent(eventName, patch);
421
- events.push(next);
343
+ events.push(event);
344
+ };
345
+ // Emit host/started immediately.
346
+ push(factory.build({
347
+ correlation: { scope: "host", hostRunId },
348
+ kind: { lifecycle: "host", phase: "started" },
349
+ extensions: hostExtensions,
350
+ }));
351
+ // Helper to lazily start a collection run the first time we see collection activity.
352
+ const ensureCollectionStarted = () => {
353
+ if (collectionRunId)
354
+ return;
355
+ if (!collectionSource)
356
+ return;
357
+ collectionRunId = randomId("col");
358
+ push(factory.build({
359
+ correlation: {
360
+ scope: "collection",
361
+ hostRunId,
362
+ collectionRunId,
363
+ source: collectionSource,
364
+ },
365
+ kind: { lifecycle: "collection", phase: "started" },
366
+ connectorVersion,
367
+ authMode,
368
+ }));
369
+ };
370
+ const emitCollectionEvent = (kind) => {
371
+ ensureCollectionStarted();
372
+ if (!collectionRunId || !collectionSource)
373
+ return;
374
+ push(factory.build({
375
+ correlation: {
376
+ scope: "collection",
377
+ hostRunId,
378
+ collectionRunId,
379
+ source: collectionSource,
380
+ },
381
+ kind,
382
+ connectorVersion,
383
+ }));
384
+ };
385
+ const ensureSyncRunId = () => {
386
+ if (!syncRunId)
387
+ syncRunId = randomId("sync");
388
+ return syncRunId;
389
+ };
390
+ const emitSyncEvent = (kind) => {
391
+ if (!collectionSource)
392
+ return;
393
+ push(factory.build({
394
+ correlation: {
395
+ scope: "sync",
396
+ hostRunId,
397
+ syncRunId: ensureSyncRunId(),
398
+ source: collectionSource,
399
+ ...(collectionRunId ? { collectionRunId } : {}),
400
+ },
401
+ kind,
402
+ }));
403
+ };
404
+ const countScopeResults = (scopeResults) => {
405
+ const stored = scopeResults?.filter((s) => s.status === "stored").length ?? 0;
406
+ const failed = scopeResults?.filter((s) => s.status === "failed").length ?? 0;
407
+ return { stored, failed };
422
408
  };
423
- push("command_started", {
424
- metadata: sanitizeMetadata({
425
- launchMode: context.options.detach ? "detached" : "direct",
426
- inputMode: context.options.ipc
427
- ? "ipc"
428
- : context.options.noInput
429
- ? "no_input"
430
- : "interactive",
431
- }),
432
- });
433
409
  return {
434
410
  trackCliEvent(event) {
435
- const mapped = mapCliEventToTelemetry(event);
436
- if (!mapped) {
437
- if (event.type === "outcome") {
438
- latestOutcome = event.status ?? null;
439
- latestErrorClass = resolveErrorClass(classifyError(event.reason), classifyOutcomeStatus(event.status), latestErrorClass);
411
+ // Capture connector version / source / auth mode from any event that carries them.
412
+ if ("source" in event && event.source)
413
+ collectionSource = event.source;
414
+ switch (event.type) {
415
+ case "setup-check":
416
+ // Setup-phase events are producer-specific and don't fit the shared
417
+ // lifecycle. Attach them to the host extensions instead.
418
+ hostExtensions.runtimeCheckCompleted = true;
419
+ break;
420
+ case "setup-complete":
421
+ hostExtensions.runtimeInstallCompleted = true;
422
+ break;
423
+ case "connector-resolved":
424
+ hostExtensions.connectorResolved = true;
425
+ break;
426
+ case "needs-input":
427
+ emitCollectionEvent({
428
+ lifecycle: "collection",
429
+ phase: "needs_input",
430
+ ...(mapInteractionKind(event.fields?.join(",") ?? null)
431
+ ? {
432
+ interactionKind: mapInteractionKind(event.fields?.join(",") ?? null),
433
+ }
434
+ : {}),
435
+ });
436
+ break;
437
+ case "legacy-auth":
438
+ // Legacy auth is effectively a "needs manual action" interaction.
439
+ emitCollectionEvent({
440
+ lifecycle: "collection",
441
+ phase: "needs_input",
442
+ interactionKind: "manual_action",
443
+ });
444
+ break;
445
+ case "collection-complete":
446
+ emitCollectionEvent({
447
+ lifecycle: "collection",
448
+ phase: "terminal",
449
+ outcome: "success",
450
+ });
451
+ break;
452
+ case "runtime-error":
453
+ emitCollectionEvent({
454
+ lifecycle: "collection",
455
+ phase: "terminal",
456
+ outcome: "failure",
457
+ errorClass: classifyCanonicalError(event.message),
458
+ });
459
+ break;
460
+ case "ingest-started":
461
+ emitSyncEvent({ lifecycle: "sync", phase: "started" });
462
+ break;
463
+ case "ingest-complete": {
464
+ const { stored, failed } = countScopeResults(event.scopeResults);
465
+ emitSyncEvent({
466
+ lifecycle: "sync",
467
+ phase: "terminal",
468
+ outcome: "success",
469
+ storedScopeCount: stored,
470
+ failedScopeCount: failed,
471
+ });
472
+ syncRunId = null;
473
+ break;
440
474
  }
441
- return;
475
+ case "ingest-partial": {
476
+ // Partial in the CLI means "some scopes stored, some failed" — in
477
+ // the canonical contract that is `outcome: success` with
478
+ // failedScopeCount > 0. The UI derives the "partial" label.
479
+ const { stored, failed } = countScopeResults(event.scopeResults);
480
+ emitSyncEvent({
481
+ lifecycle: "sync",
482
+ phase: "terminal",
483
+ outcome: "success",
484
+ storedScopeCount: stored,
485
+ failedScopeCount: failed,
486
+ });
487
+ syncRunId = null;
488
+ break;
489
+ }
490
+ case "ingest-failed":
491
+ emitSyncEvent({
492
+ lifecycle: "sync",
493
+ phase: "terminal",
494
+ outcome: "failure",
495
+ errorClass: classifyCanonicalError("ingest_failed"),
496
+ });
497
+ syncRunId = null;
498
+ break;
499
+ case "ingest-skipped":
500
+ // Skip is standalone — no preceding `started` event.
501
+ syncRunId = randomId("sync"); // fresh ID for the standalone skip
502
+ emitSyncEvent({
503
+ lifecycle: "sync",
504
+ phase: "skipped",
505
+ reason: classifyCanonicalError(event.reason) ===
506
+ "personal_server_unavailable"
507
+ ? "server_unavailable"
508
+ : "not_requested",
509
+ });
510
+ syncRunId = null;
511
+ break;
512
+ case "outcome":
513
+ latestOutcomeRaw = event.status ?? null;
514
+ break;
515
+ case "progress-update":
516
+ case "status-update":
517
+ case "headed-required":
518
+ case "jpeg":
519
+ // Not modeled in canonical telemetry.
520
+ break;
442
521
  }
443
- push(mapped.eventName, {
444
- ...mapped.patch,
445
- source: event.source ?? context.source ?? null,
446
- });
447
- },
448
- trackCustomEvent(eventName, patch = {}) {
449
- push(eventName, patch);
450
522
  },
451
523
  markCommandResult(result) {
452
- latestOutcome = result.outcome ?? latestOutcome;
453
- latestErrorClass = resolveErrorClass(result.errorClass, latestErrorClass, classifyOutcomeStatus(latestOutcome));
454
524
  const durationMs = Date.now() - startedAt;
525
+ latestOutcomeRaw = result.outcome ?? latestOutcomeRaw;
526
+ // If a collection run was started but never terminated, close it here.
527
+ // If the CLI exited with a non-zero code, record collection_failed;
528
+ // otherwise let the lifecycle's own terminal stand.
529
+ if (collectionRunId && collectionSource) {
530
+ const hasCollectionTerminal = events.some((e) => e.correlation.scope === "collection" &&
531
+ e.correlation.collectionRunId === collectionRunId &&
532
+ e.kind.lifecycle === "collection" &&
533
+ e.kind.phase === "terminal");
534
+ if (!hasCollectionTerminal) {
535
+ if (result.exitCode === 0) {
536
+ push(factory.build({
537
+ correlation: {
538
+ scope: "collection",
539
+ hostRunId,
540
+ collectionRunId,
541
+ source: collectionSource,
542
+ },
543
+ kind: {
544
+ lifecycle: "collection",
545
+ phase: "terminal",
546
+ outcome: "success",
547
+ },
548
+ }));
549
+ }
550
+ else {
551
+ push(factory.build({
552
+ correlation: {
553
+ scope: "collection",
554
+ hostRunId,
555
+ collectionRunId,
556
+ source: collectionSource,
557
+ },
558
+ kind: {
559
+ lifecycle: "collection",
560
+ phase: "terminal",
561
+ outcome: "failure",
562
+ errorClass: classifyCanonicalError(result.errorClass ?? latestOutcomeRaw),
563
+ },
564
+ }));
565
+ }
566
+ }
567
+ }
568
+ // Host terminal.
455
569
  if (result.exitCode === 0) {
456
- push("command_completed", {
570
+ push(factory.build({
571
+ correlation: { scope: "host", hostRunId },
572
+ kind: { lifecycle: "host", phase: "terminal", outcome: "success" },
457
573
  durationMs,
458
- outcome: latestOutcome,
459
- errorClass: null,
460
- });
461
- return;
574
+ }));
575
+ }
576
+ else {
577
+ push(factory.build({
578
+ correlation: { scope: "host", hostRunId },
579
+ kind: {
580
+ lifecycle: "host",
581
+ phase: "terminal",
582
+ outcome: "failure",
583
+ errorClass: classifyCanonicalError(result.errorClass ?? latestOutcomeRaw),
584
+ },
585
+ durationMs,
586
+ }));
462
587
  }
463
- push("command_failed", {
464
- durationMs,
465
- outcome: latestOutcome,
466
- errorClass: latestErrorClass ?? "unknown",
467
- });
468
588
  },
469
589
  async persist() {
470
- if (persisted) {
590
+ if (persisted)
471
591
  return;
472
- }
473
592
  persisted = true;
474
593
  if (state.mode === "debug") {
475
- for (const envelope of splitIntoEnvelopes(events, context.cliVersion)) {
594
+ for (const envelope of splitIntoEnvelopes(events)) {
476
595
  process.stderr.write(`${JSON.stringify(envelope)}\n`);
477
596
  }
478
597
  return;
479
598
  }
480
- if (!state.enabled) {
599
+ if (!state.enabled)
481
600
  return;
482
- }
483
- const envelopes = splitIntoEnvelopes(events, context.cliVersion);
484
- const runId = events[0]?.runId ?? randomId("run");
601
+ const envelopes = splitIntoEnvelopes(events);
485
602
  for (const envelope of envelopes) {
486
- await writeEnvelope(envelope, runId);
603
+ await writeEnvelope(envelope, hostRunId);
487
604
  }
488
605
  },
606
+ trackCustomEvent() {
607
+ // No-op. See the CliTelemetrySession interface doc.
608
+ },
489
609
  async flush() {
490
610
  await flushTelemetryOutbox();
491
611
  },
492
612
  };
493
613
  }
614
+ // ── Deprecated ──────────────────────────────────────────────────────────────
615
+ /** @deprecated trackCustomEvent on the session is a no-op in the canonical
616
+ * model. This top-level helper is also a no-op — kept only to satisfy
617
+ * existing call sites. */
618
+ export function trackActiveTelemetryEvent(_eventName, _patch) {
619
+ /* intentionally blank */
620
+ }
494
621
  //# sourceMappingURL=telemetry.js.map