@opendatalabs/connect 0.12.1 → 0.13.0

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,127 +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"))
48
- return "prompt_cancelled";
49
- if (value.includes("auth"))
50
- return "auth_failed";
51
- if (value.includes("needs input"))
52
- return "needs_input";
53
- if (value.includes("legacy"))
54
- return "legacy_auth";
55
- if (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")) {
56
51
  return "personal_server_unavailable";
57
- if (value.includes("ingest"))
58
- return "ingest_failed";
59
- if (value.includes("connector"))
60
- return "connector_unavailable";
61
- if (value.includes("runtime"))
52
+ }
53
+ if (normalized.includes("auth") || normalized.includes("legacy")) {
54
+ return "auth_failed";
55
+ }
56
+ if (normalized.includes("timeout") || normalized.includes("timed out")) {
57
+ return "timeout";
58
+ }
59
+ if (normalized.includes("network")) {
60
+ return "network_error";
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")) {
62
68
  return "runtime_error";
69
+ }
63
70
  return "unknown";
64
71
  }
65
- function mapCliEventToTelemetry(event) {
66
- switch (event.type) {
67
- case "setup-check":
68
- return {
69
- eventName: "runtime_check_completed",
70
- patch: {
71
- metadata: sanitizeMetadata({ runtime: event.runtime ?? null }),
72
- },
73
- };
74
- case "setup-complete":
75
- return {
76
- eventName: "runtime_install_completed",
77
- patch: {
78
- metadata: sanitizeMetadata({ runtime: event.runtime ?? null }),
79
- },
80
- };
81
- case "connector-resolved":
82
- return { eventName: "connector_resolved" };
83
- case "needs-input":
84
- return {
85
- eventName: "input_required",
86
- patch: {
87
- errorClass: "needs_input",
88
- metadata: sanitizeMetadata({ fieldCount: event.fields?.length ?? 0 }),
89
- },
90
- };
91
- case "legacy-auth":
92
- return {
93
- eventName: "legacy_auth_required",
94
- patch: { errorClass: "legacy_auth" },
95
- };
96
- case "collection-complete":
97
- return { eventName: "collection_completed" };
98
- case "runtime-error":
99
- return {
100
- eventName: "collection_failed",
101
- patch: { errorClass: classifyError(event.message) ?? "runtime_error" },
102
- };
103
- case "ingest-started":
104
- return { eventName: "ingest_started" };
105
- case "ingest-complete":
106
- return {
107
- eventName: "ingest_completed",
108
- patch: countScopeResults(event.scopeResults),
109
- };
110
- case "ingest-partial":
111
- return {
112
- eventName: "ingest_partial",
113
- patch: {
114
- ...countScopeResults(event.scopeResults),
115
- errorClass: "ingest_failed",
116
- },
117
- };
118
- case "ingest-failed":
119
- return {
120
- eventName: "ingest_failed",
121
- patch: {
122
- ...countScopeResults(event.scopeResults),
123
- errorClass: "ingest_failed",
124
- },
125
- };
126
- case "ingest-skipped":
127
- return {
128
- eventName: "ingest_skipped",
129
- patch: {
130
- errorClass: classifyError(event.reason) ?? null,
131
- metadata: sanitizeMetadata({ reason: event.reason ?? null }),
132
- },
133
- };
134
- case "outcome":
135
- case "progress-update":
136
- case "status-update":
137
- case "headed-required":
138
- case "jpeg":
139
- return null;
140
- }
141
- 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";
142
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.
143
91
  async function ensureOutboxDir() {
144
92
  await fs.mkdir(getTelemetryOutboxDir(), { recursive: true });
145
93
  }
@@ -222,52 +170,64 @@ async function resolveTelemetryState(localOnly = false) {
222
170
  queuedBatches,
223
171
  };
224
172
  }
173
+ // ── Event factory ───────────────────────────────────────────────────────────
174
+ //
175
+ // Builds canonical TelemetryEvent values with identity + time + attribution +
176
+ // context auto-populated. The caller supplies correlation + kind.
225
177
  function createEventFactory(context, state) {
226
- const runId = randomId("run");
227
- const base = {
228
- installId: state.installId,
229
- runId,
230
- command: context.command,
231
- subcommand: context.subcommand ?? null,
232
- source: context.source ?? null,
233
- connectorVersion: null,
234
- authMode: null,
235
- platform: `${process.platform}-${os.arch()}`,
236
- os: process.platform,
237
- arch: os.arch(),
238
- cliVersion: context.cliVersion,
239
- channel: context.channel,
240
- installMethod: context.installMethod,
241
- ci: Boolean(process.env.CI),
242
- agent: Boolean(process.env.AGENT),
243
- 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
+ },
244
219
  };
245
- return (eventName, patch = {}) => ({
246
- eventId: randomId("evt"),
247
- eventVersion: EVENT_VERSION,
248
- timestamp: nowIso(),
249
- eventName,
250
- ...base,
251
- ...patch,
252
- outcome: patch.outcome ?? null,
253
- errorClass: patch.errorClass ?? null,
254
- durationMs: patch.durationMs ?? null,
255
- storedScopeCount: patch.storedScopeCount ?? null,
256
- failedScopeCount: patch.failedScopeCount ?? null,
257
- metadata: sanitizeMetadata(patch.metadata),
258
- });
259
220
  }
260
- function splitIntoEnvelopes(events, cliVersion) {
221
+ // ── Batch serialization ─────────────────────────────────────────────────────
222
+ function splitIntoEnvelopes(events) {
261
223
  const envelopes = [];
262
224
  let current = [];
263
225
  const flushCurrent = () => {
264
- if (current.length === 0) {
226
+ if (current.length === 0)
265
227
  return;
266
- }
267
228
  envelopes.push({
268
229
  batchId: randomId("batch"),
269
230
  sentAt: nowIso(),
270
- client: { name: "vana-cli", version: cliVersion },
271
231
  events: current,
272
232
  });
273
233
  current = [];
@@ -277,7 +237,6 @@ function splitIntoEnvelopes(events, cliVersion) {
277
237
  const candidate = {
278
238
  batchId: "batch_candidate",
279
239
  sentAt: nowIso(),
280
- client: { name: "vana-cli", version: cliVersion },
281
240
  events: current,
282
241
  };
283
242
  const tooManyEvents = current.length > MAX_EVENTS_PER_BATCH;
@@ -285,25 +244,23 @@ function splitIntoEnvelopes(events, cliVersion) {
285
244
  if (tooManyEvents || tooLarge) {
286
245
  const overflow = current.pop();
287
246
  flushCurrent();
288
- if (overflow) {
247
+ if (overflow)
289
248
  current.push(overflow);
290
- }
291
249
  }
292
250
  }
293
251
  flushCurrent();
294
252
  return envelopes;
295
253
  }
296
- async function writeEnvelope(envelope, runId) {
254
+ async function writeEnvelope(envelope, hostRunId) {
297
255
  await ensureOutboxDir();
298
- const filename = `${Date.now()}-${process.pid}-${runId}-${crypto.randomUUID()}.json`;
256
+ const filename = `${Date.now()}-${process.pid}-${hostRunId}-${crypto.randomUUID()}.json`;
299
257
  const outboxPath = path.join(getTelemetryOutboxDir(), filename);
300
258
  await fs.writeFile(outboxPath, `${JSON.stringify(envelope)}\n`, "utf8");
301
259
  }
302
260
  export async function flushTelemetryOutbox() {
303
261
  const state = await resolveTelemetryState();
304
- if (!state.enabled || state.mode !== "normal") {
262
+ if (!state.enabled || state.mode !== "normal")
305
263
  return;
306
- }
307
264
  const files = (await listOutboxFiles()).slice(0, MAX_FILES_PER_FLUSH);
308
265
  for (const filePath of files) {
309
266
  let contents;
@@ -318,7 +275,7 @@ export async function flushTelemetryOutbox() {
318
275
  method: "POST",
319
276
  headers: {
320
277
  "Content-Type": "application/json",
321
- "User-Agent": `vana-cli/${JSON.parse(contents).client?.version ?? "unknown"}`,
278
+ "User-Agent": `vana-cli/unknown`,
322
279
  },
323
280
  body: contents,
324
281
  signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS),
@@ -332,6 +289,7 @@ export async function flushTelemetryOutbox() {
332
289
  }
333
290
  }
334
291
  }
292
+ // ── Public helpers ──────────────────────────────────────────────────────────
335
293
  export async function getTelemetryStatus() {
336
294
  const state = await resolveTelemetryState();
337
295
  return {
@@ -352,93 +310,312 @@ export function setActiveTelemetrySession(session) {
352
310
  export function getActiveTelemetrySession() {
353
311
  return activeSession;
354
312
  }
355
- export function trackActiveTelemetryEvent(eventName, patch) {
356
- activeSession?.trackCustomEvent(eventName, patch);
357
- }
313
+ // ── Session ─────────────────────────────────────────────────────────────────
358
314
  export async function createCliTelemetrySession(context) {
359
315
  const state = await resolveTelemetryState(Boolean(context.localOnly));
360
- const buildEvent = createEventFactory(context, state);
316
+ const factory = createEventFactory(context, state);
361
317
  const startedAt = Date.now();
362
318
  const events = [];
363
- let latestOutcome = null;
364
- 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;
365
328
  let persisted = false;
366
- const push = (eventName, patch = {}) => {
367
- 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")
368
342
  return;
369
- }
370
- const next = buildEvent(eventName, patch);
371
- 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 };
372
408
  };
373
- push("command_started", {
374
- metadata: sanitizeMetadata({
375
- launchMode: context.options.detach ? "detached" : "direct",
376
- inputMode: context.options.ipc
377
- ? "ipc"
378
- : context.options.noInput
379
- ? "no_input"
380
- : "interactive",
381
- }),
382
- });
383
409
  return {
384
410
  trackCliEvent(event) {
385
- const mapped = mapCliEventToTelemetry(event);
386
- if (!mapped) {
387
- if (event.type === "outcome") {
388
- latestOutcome = event.status ?? null;
389
- latestErrorClass = classifyError(event.reason) ?? 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;
390
474
  }
391
- 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;
392
521
  }
393
- push(mapped.eventName, {
394
- ...mapped.patch,
395
- source: event.source ?? context.source ?? null,
396
- });
397
- },
398
- trackCustomEvent(eventName, patch = {}) {
399
- push(eventName, patch);
400
522
  },
401
523
  markCommandResult(result) {
402
- latestOutcome = result.outcome ?? latestOutcome;
403
- latestErrorClass = result.errorClass ?? latestErrorClass;
404
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.
405
569
  if (result.exitCode === 0) {
406
- push("command_completed", {
570
+ push(factory.build({
571
+ correlation: { scope: "host", hostRunId },
572
+ kind: { lifecycle: "host", phase: "terminal", outcome: "success" },
407
573
  durationMs,
408
- outcome: latestOutcome,
409
- errorClass: null,
410
- });
411
- 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
+ }));
412
587
  }
413
- push("command_failed", {
414
- durationMs,
415
- outcome: latestOutcome,
416
- errorClass: latestErrorClass ?? "unknown",
417
- });
418
588
  },
419
589
  async persist() {
420
- if (persisted) {
590
+ if (persisted)
421
591
  return;
422
- }
423
592
  persisted = true;
424
593
  if (state.mode === "debug") {
425
- for (const envelope of splitIntoEnvelopes(events, context.cliVersion)) {
594
+ for (const envelope of splitIntoEnvelopes(events)) {
426
595
  process.stderr.write(`${JSON.stringify(envelope)}\n`);
427
596
  }
428
597
  return;
429
598
  }
430
- if (!state.enabled) {
599
+ if (!state.enabled)
431
600
  return;
432
- }
433
- const envelopes = splitIntoEnvelopes(events, context.cliVersion);
434
- const runId = events[0]?.runId ?? randomId("run");
601
+ const envelopes = splitIntoEnvelopes(events);
435
602
  for (const envelope of envelopes) {
436
- await writeEnvelope(envelope, runId);
603
+ await writeEnvelope(envelope, hostRunId);
437
604
  }
438
605
  },
606
+ trackCustomEvent() {
607
+ // No-op. See the CliTelemetrySession interface doc.
608
+ },
439
609
  async flush() {
440
610
  await flushTelemetryOutbox();
441
611
  },
442
612
  };
443
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
+ }
444
621
  //# sourceMappingURL=telemetry.js.map