@agentstrack/collector 0.2.1 → 0.3.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +78 -28
  3. package/dist/adapters/claude.d.ts +18 -0
  4. package/dist/adapters/claude.js +153 -45
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/adapters/codex.d.ts +15 -1
  7. package/dist/adapters/codex.js +81 -33
  8. package/dist/adapters/codex.js.map +1 -1
  9. package/dist/adapters/opencode.d.ts +17 -6
  10. package/dist/adapters/opencode.js +72 -26
  11. package/dist/adapters/opencode.js.map +1 -1
  12. package/dist/adapters/types.d.ts +16 -0
  13. package/dist/adapters/types.js +61 -0
  14. package/dist/adapters/types.js.map +1 -1
  15. package/dist/cli.js +164 -33
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/service.js +40 -10
  18. package/dist/commands/service.js.map +1 -1
  19. package/dist/config.d.ts +1 -1
  20. package/dist/config.js +18 -5
  21. package/dist/config.js.map +1 -1
  22. package/dist/daemon.d.ts +73 -21
  23. package/dist/daemon.js +350 -119
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/git/commits.d.ts +7 -1
  26. package/dist/git/commits.js +36 -17
  27. package/dist/git/commits.js.map +1 -1
  28. package/dist/git/repo.d.ts +13 -4
  29. package/dist/git/repo.js +34 -20
  30. package/dist/git/repo.js.map +1 -1
  31. package/dist/machine.d.ts +27 -0
  32. package/dist/machine.js +46 -0
  33. package/dist/machine.js.map +1 -0
  34. package/dist/privacy/pipeline.d.ts +3 -0
  35. package/dist/privacy/pipeline.js +4 -2
  36. package/dist/privacy/pipeline.js.map +1 -1
  37. package/dist/privacy/redact.d.ts +10 -1
  38. package/dist/privacy/redact.js +29 -3
  39. package/dist/privacy/redact.js.map +1 -1
  40. package/dist/queue/event-id.d.ts +9 -0
  41. package/dist/queue/event-id.js +15 -0
  42. package/dist/queue/event-id.js.map +1 -0
  43. package/dist/queue/spool.d.ts +24 -5
  44. package/dist/queue/spool.js +89 -33
  45. package/dist/queue/spool.js.map +1 -1
  46. package/dist/queue/tailer.d.ts +27 -4
  47. package/dist/queue/tailer.js +89 -28
  48. package/dist/queue/tailer.js.map +1 -1
  49. package/dist/transport/client.d.ts +37 -13
  50. package/dist/transport/client.js +50 -3
  51. package/dist/transport/client.js.map +1 -1
  52. package/package.json +2 -2
package/dist/daemon.js CHANGED
@@ -1,24 +1,34 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { appendFileSync } from 'node:fs';
3
- import { hostname, arch, platform } from 'node:os';
4
- import { readdirSync, statSync, existsSync } from 'node:fs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { appendFileSync, renameSync, statSync } from 'node:fs';
3
+ import { readdirSync, existsSync } from 'node:fs';
5
4
  import { join } from 'node:path';
6
5
  import { loadConfig, saveConfig, SPOOL_PATH, LOG_PATH } from './config.js';
7
6
  import { Spool } from './queue/spool.js';
7
+ import { machineInfo } from './machine.js';
8
8
  import { tailFile } from './queue/tailer.js';
9
- import { ApiClient, backoffMs } from './transport/client.js';
9
+ import { deterministicEventId } from './queue/event-id.js';
10
+ export { deterministicEventId };
11
+ import { ApiClient, ApiError, backoffMs, VERSION } from './transport/client.js';
10
12
  import { ClaudeCodeAdapter } from './adapters/claude.js';
11
13
  import { CodexAdapter } from './adapters/codex.js';
12
14
  import { OpenCodeAdapter } from './adapters/opencode.js';
13
15
  import { applyPrivacy } from './privacy/pipeline.js';
16
+ import { compileRules } from './privacy/redact.js';
14
17
  import { clampPrivacyMode } from './privacy/mode.js';
15
18
  import { isExcluded } from './privacy/paths.js';
16
19
  import { describeRepo } from './git/repo.js';
17
20
  import { GitCommitWatcher } from './git/commits.js';
18
21
  import { SCHEMA_VERSION } from './schema.js';
22
+ export { VERSION };
23
+ const LOG_MAX_BYTES = 5 * 1024 * 1024;
19
24
  export function log(message) {
20
25
  const line = `${new Date().toISOString()} ${message}\n`;
21
26
  try {
27
+ // One rotation, no compression: enough to keep a service's log bounded
28
+ // without making `tail -f` lose the current file.
29
+ if ((statSync(LOG_PATH, { throwIfNoEntry: false })?.size ?? 0) > LOG_MAX_BYTES) {
30
+ renameSync(LOG_PATH, `${LOG_PATH}.1`);
31
+ }
22
32
  appendFileSync(LOG_PATH, line);
23
33
  }
24
34
  catch {
@@ -91,6 +101,17 @@ export function listTranscripts(dir, maxAgeDays = 7) {
91
101
  walk(dir, 0);
92
102
  return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
93
103
  }
104
+ /** Splits one peeked wave into `batchSize` slices, every event in exactly one. */
105
+ export function chunkWave(wave, batchSize) {
106
+ const batches = [];
107
+ for (let i = 0; i < wave.length; i += batchSize)
108
+ batches.push(wave.slice(i, i + batchSize));
109
+ return batches;
110
+ }
111
+ /** Waves one daemon tick may send before scan() gets the loop back. */
112
+ const MAX_WAVES_PER_TICK = 5;
113
+ /** Spool meta key naming the current upload pause (`<reason>: <detail>`); `status` prints it. */
114
+ export const UPLOAD_PAUSE_META = 'upload_paused_reason';
94
115
  export class Collector {
95
116
  config;
96
117
  spool;
@@ -99,6 +120,7 @@ export class Collector {
99
120
  /** Null when git metadata is switched off — then we never shell out to git. */
100
121
  commitWatcher;
101
122
  serverConfig = null;
123
+ orgRules = [];
102
124
  /**
103
125
  * When this collector started, and therefore the earliest event it can
104
126
  * honestly attribute to an account.
@@ -112,24 +134,34 @@ export class Collector {
112
134
  */
113
135
  liveSinceMs = Date.now();
114
136
  uploadFailures = 0;
115
- /** Shrinks on 413, recovers on success. Never below 1. */
137
+ /** Shrinks on 413, recovers on success. Never below 1, never above maxBatchSize. */
116
138
  batchSize;
139
+ /** Local batch_size clamped to the server's max_batch_events. */
140
+ maxBatchSize;
141
+ /** Uploads are gated on this instead of sleeping, so scanning never stops. */
142
+ nextUploadAt = 0;
143
+ pausedReason = null;
144
+ /** cwd -> repo, valid for one scan pass. */
145
+ repoCache = new Map();
146
+ /** agent::session_id -> last activity, for idle session.ended. */
147
+ openSessions = new Map();
117
148
  running = false;
118
- constructor(config) {
149
+ constructor(config, deps = {}) {
119
150
  this.config = config;
120
151
  if (!config.api_key)
121
152
  throw new Error('Not logged in. Run: agentstrack login <api-key>');
122
- this.spool = new Spool(SPOOL_PATH);
123
- this.client = new ApiClient({ apiUrl: config.api_url, apiKey: config.api_key });
153
+ this.spool = deps.spool ?? new Spool(SPOOL_PATH);
154
+ this.client = deps.client ?? new ApiClient({ apiUrl: config.api_url, apiKey: config.api_key });
124
155
  this.adapters = buildAdapters(config);
125
156
  this.commitWatcher = config.tracking.git_metadata ? new GitCommitWatcher() : null;
126
157
  this.batchSize = config.upload.batch_size;
158
+ this.maxBatchSize = config.upload.batch_size;
127
159
  }
128
160
  async start() {
129
161
  this.running = true;
130
162
  await this.ensureRegistered();
131
163
  await this.refreshServerConfig();
132
- log(`Collector started — agents: ${this.adapters.map((a) => a.id).join(', ')}`);
164
+ log(`Collector ${VERSION} started — agents: ${this.adapters.map((a) => a.id).join(', ')}`);
133
165
  const scanInterval = 5_000;
134
166
  const uploadInterval = this.config.upload.interval_seconds * 1000;
135
167
  let lastUpload = 0;
@@ -142,19 +174,29 @@ export class Collector {
142
174
  log(`Scan error: ${errorMessage(error)}`);
143
175
  }
144
176
  const now = Date.now();
145
- if (now - lastUpload >= uploadInterval || this.spool.depth() >= this.batchSize) {
146
- lastUpload = now;
147
- await this.flush();
177
+ let more = false;
178
+ try {
179
+ if (now >= this.nextUploadAt && (now - lastUpload >= uploadInterval || this.spool.depth() >= this.batchSize)) {
180
+ lastUpload = now;
181
+ more = await this.flush(MAX_WAVES_PER_TICK);
182
+ }
183
+ if (now - lastHealth >= 60_000) {
184
+ lastHealth = now;
185
+ await this.reportHealth();
186
+ }
148
187
  }
149
- if (now - lastHealth >= 60_000) {
150
- lastHealth = now;
151
- await this.reportHealth();
188
+ catch (error) {
189
+ log(`Upload error: ${errorMessage(error)}`);
152
190
  }
153
- await sleep(scanInterval);
191
+ // A backlog alternates scan and flush without the 5s pause between them.
192
+ await sleep(more ? 0 : scanInterval);
154
193
  }
155
194
  }
156
195
  stop() {
157
196
  this.running = false;
197
+ // Whatever was open when we went down ended for a reason we cannot see.
198
+ if (this.config.collector_id)
199
+ this.endIdleSessions(0, 'unknown');
158
200
  this.spool.close();
159
201
  }
160
202
  /** Registers this device once and remembers the id. */
@@ -163,9 +205,7 @@ export class Collector {
163
205
  return;
164
206
  const agents = await Promise.all(this.adapters.map(async (a) => ({ agent: a.id, version: (await a.detect()).version })));
165
207
  const result = await this.client.registerCollector({
166
- hostname: hostname(),
167
- os: platform(),
168
- arch: arch(),
208
+ ...machineInfo(),
169
209
  version: VERSION,
170
210
  // Report what this device enforces, so a session records the mode that
171
211
  // actually applied rather than the org default.
@@ -179,6 +219,8 @@ export class Collector {
179
219
  async refreshServerConfig() {
180
220
  try {
181
221
  this.serverConfig = await this.client.getConfig();
222
+ // Compiled once per config refresh, not once per event.
223
+ this.orgRules = compileRules(this.serverConfig.redaction_rules);
182
224
  // The org sets a ceiling; a stricter local mode is honoured, a looser one
183
225
  // is not. Same rule as `login`, shared so the two cannot drift.
184
226
  const effective = clampPrivacyMode(this.config.privacy.mode, this.serverConfig.privacy_mode);
@@ -186,6 +228,13 @@ export class Collector {
186
228
  log(`Local privacy mode '${this.config.privacy.mode}' exceeds org policy '${this.serverConfig.privacy_mode}' — using org policy`);
187
229
  this.config = { ...this.config, privacy: { ...this.config.privacy, mode: effective } };
188
230
  }
231
+ // The server's ceiling wins over the local batch_size; anything above it
232
+ // is a guaranteed 413 on every wave.
233
+ const serverMax = this.serverConfig.max_batch_events;
234
+ if (Number.isInteger(serverMax) && serverMax > 0) {
235
+ this.maxBatchSize = Math.min(this.config.upload.batch_size, serverMax);
236
+ this.batchSize = Math.min(this.batchSize, this.maxBatchSize);
237
+ }
189
238
  }
190
239
  catch (error) {
191
240
  log(`Could not fetch server config, using local defaults: ${errorMessage(error)}`);
@@ -196,6 +245,10 @@ export class Collector {
196
245
  const collectorId = this.config.collector_id;
197
246
  if (!collectorId)
198
247
  return;
248
+ this.repoCache.clear();
249
+ let queued = 0;
250
+ let files = 0;
251
+ let skipped = 0;
199
252
  for (const adapter of this.adapters) {
200
253
  const detection = await adapter.detect();
201
254
  if (!detection.installed)
@@ -205,34 +258,43 @@ export class Collector {
205
258
  const account = adapter.account?.();
206
259
  for (const watchPath of detection.watchPaths) {
207
260
  for (const file of listTranscripts(watchPath, this.config.tracking.max_age_days)) {
208
- const { lines } = await tailFile(file, this.spool);
209
- if (lines.length === 0)
210
- continue;
211
- const normalized = [];
212
- for (const line of lines) {
213
- try {
214
- normalized.push(...adapter.normalize(line, { collectorId, sourceFile: file }));
215
- }
216
- catch (error) {
217
- // A parser bug on one line must not stop the whole file.
218
- log(`normalize error in ${file}: ${errorMessage(error)}`);
219
- }
261
+ // One unreadable file must not stall every other file, every scan.
262
+ try {
263
+ const result = await tailFile(file, this.spool, (lines) => {
264
+ const normalized = [];
265
+ for (const { text, offset } of lines) {
266
+ let events;
267
+ try {
268
+ events = adapter.normalize(text, { collectorId, sourceFile: file });
269
+ }
270
+ catch (error) {
271
+ // A parser bug on one line must not stop the whole file.
272
+ log(`normalize error in ${file}: ${errorMessage(error)}`);
273
+ continue;
274
+ }
275
+ if (events.length === 0)
276
+ continue;
277
+ // Same file, same byte, same text => same id, however often it is re-read.
278
+ const line = createHash('sha256').update(`${adapter.id}\n${file}\n${offset}\n`).update(text).digest('hex');
279
+ events.forEach((event, i) => normalized.push({ ...event, eventId: event.eventId ?? deterministicEventId(`${line}:${i}`) }));
280
+ }
281
+ this.commitWatcher?.observe(normalized);
282
+ queued += this.enqueue(normalized, collectorId, account);
283
+ });
284
+ if (result.lines > 0)
285
+ files += 1;
286
+ skipped += result.skipped;
287
+ }
288
+ catch (error) {
289
+ log(`tail error in ${file}: ${errorMessage(error)}`);
220
290
  }
221
- this.commitWatcher?.observe(normalized);
222
- this.enqueue(normalized, collectorId, account);
223
291
  }
224
292
  }
225
293
  // Database-backed agents have no lines to tail; they hand us events on
226
294
  // the same cycle, under the same gating and the same privacy pipeline.
227
295
  if (adapter.poll) {
228
296
  try {
229
- const polled = await adapter.poll({
230
- collectorId,
231
- getMeta: (key) => this.spool.getMeta(key),
232
- setMeta: (key, value) => this.spool.setMeta(key, value),
233
- });
234
- this.commitWatcher?.observe(polled);
235
- this.enqueue(polled, collectorId, account);
297
+ queued += await this.pollAdapter(adapter.poll.bind(adapter), collectorId, account);
236
298
  }
237
299
  catch (error) {
238
300
  log(`poll error in ${adapter.id}: ${errorMessage(error)}`);
@@ -244,21 +306,82 @@ export class Collector {
244
306
  // actually touched, once per scan cycle.
245
307
  if (this.commitWatcher) {
246
308
  try {
247
- this.enqueue(await this.commitWatcher.poll(), collectorId);
309
+ queued += this.enqueue(await this.commitWatcher.poll(), collectorId);
248
310
  }
249
311
  catch (error) {
250
312
  log(`Commit scan error: ${errorMessage(error)}`);
251
313
  }
252
314
  }
315
+ queued += this.endIdleSessions(this.config.tracking.idle_timeout_seconds * 1000, 'timeout');
316
+ if (queued > 0)
317
+ log(`Queued ${queued} events across ${files} files`);
318
+ if (skipped > 0)
319
+ log(`Skipped ${skipped} oversized transcript lines`);
253
320
  }
254
- enqueue(normalized, collectorId, adapterAccount) {
321
+ /**
322
+ * Polls a database-backed adapter. Its cursors and "already started" markers
323
+ * are buffered and written in the same transaction as the events — an
324
+ * enqueue that fails (SQLITE_FULL) must not leave a cursor pointing past
325
+ * rows that were never spooled, or a session marked started that never was.
326
+ */
327
+ async pollAdapter(poll, collectorId, account) {
328
+ const pending = new Map();
329
+ const polled = await poll({
330
+ collectorId,
331
+ getMeta: (key) => pending.get(key) ?? this.spool.getMeta(key),
332
+ setMeta: (key, value) => void pending.set(key, value),
333
+ });
334
+ this.commitWatcher?.observe(polled);
335
+ return this.spool.transaction(() => {
336
+ const queued = this.enqueue(polled, collectorId, account);
337
+ pending.forEach((value, key) => this.spool.setMeta(key, value));
338
+ return queued;
339
+ });
340
+ }
341
+ /**
342
+ * Emits session.ended for every tracked session quiet for longer than
343
+ * `idleMs`. Claude Code and Codex never write an end marker, so without this
344
+ * their sessions stay in_progress on the server forever. `occurred_at` is
345
+ * when the timeout elapsed, not now: a backfilled session ended back then.
346
+ */
347
+ endIdleSessions(idleMs, reason) {
348
+ const collectorId = this.config.collector_id;
349
+ if (!collectorId)
350
+ return 0;
351
+ const now = Date.now();
352
+ const ended = [];
353
+ for (const [key, s] of this.openSessions) {
354
+ if (now - s.lastAtMs < idleMs)
355
+ continue;
356
+ this.openSessions.delete(key);
357
+ const at = idleMs > 0 ? s.lastAtMs + idleMs : now;
358
+ ended.push({
359
+ event: {
360
+ occurred_at: new Date(at).toISOString(),
361
+ session_id: s.sessionId,
362
+ agent: s.agent,
363
+ agent_version: s.agentVersion,
364
+ event_type: 'session.ended',
365
+ payload: { external_session_id: s.sessionId, reason },
366
+ },
367
+ eventId: deterministicEventId(`session.ended\n${s.agent}\n${s.sessionId}\n${s.lastAtMs}`),
368
+ });
369
+ }
370
+ return this.enqueue(ended, collectorId);
371
+ }
372
+ repoFor(cwd) {
373
+ if (!this.repoCache.has(cwd))
374
+ this.repoCache.set(cwd, describeRepo(cwd));
375
+ return this.repoCache.get(cwd);
376
+ }
377
+ enqueue(items, collectorId, adapterAccount) {
255
378
  const envelopes = [];
256
- for (const item of normalized) {
379
+ for (const item of items) {
257
380
  const cwd = item.cwd;
258
381
  // An excluded project never produces an event at all.
259
382
  if (cwd && isExcluded(cwd, this.config.privacy.excluded_projects))
260
383
  continue;
261
- const repo = cwd && this.config.tracking.git_metadata ? describeRepo(cwd) : item.repo;
384
+ const repo = cwd && this.config.tracking.git_metadata ? this.repoFor(cwd) : item.repo;
262
385
  const payload = repo
263
386
  ? { ...item.event.payload, repo: { ...repo, ...(item.event.payload['repo'] ?? {}) } }
264
387
  : { ...item.event.payload };
@@ -279,109 +402,208 @@ export class Collector {
279
402
  const envelope = {
280
403
  ...item.event,
281
404
  payload,
282
- event_id: randomUUID(),
405
+ event_id: item.eventId ?? randomUUID(),
283
406
  schema_version: SCHEMA_VERSION,
284
407
  collector_id: collectorId,
285
408
  };
286
409
  const { event } = applyPrivacy(envelope, {
287
410
  config: this.config,
288
411
  projectRoot: repo?.project_path,
289
- orgRules: this.serverConfig?.redaction_rules,
412
+ compiledRules: this.orgRules,
290
413
  });
291
414
  envelopes.push(event);
415
+ this.trackSession(event);
292
416
  }
293
- const written = this.spool.enqueue(envelopes);
294
- if (written > 0)
295
- log(`Queued ${written} events (depth ${this.spool.depth()})`);
417
+ return this.spool.enqueue(envelopes);
418
+ }
419
+ trackSession(event) {
420
+ const key = `${event.agent}::${event.session_id}`;
421
+ if (event.event_type === 'session.ended') {
422
+ this.openSessions.delete(key);
423
+ return;
424
+ }
425
+ const at = Date.parse(event.occurred_at);
426
+ if (!Number.isFinite(at))
427
+ return;
428
+ const open = this.openSessions.get(key);
429
+ if (open) {
430
+ open.lastAtMs = Math.max(open.lastAtMs, at);
431
+ return;
432
+ }
433
+ this.openSessions.set(key, {
434
+ agent: event.agent,
435
+ agentVersion: event.agent_version,
436
+ sessionId: event.session_id,
437
+ lastAtMs: at,
438
+ });
296
439
  }
297
- /** Drains the spool, oldest first, until it is empty or the server pushes back. */
298
440
  /**
299
- * Drains the spool.
441
+ * Drains the spool, one wave of `upload.concurrency` batches at a time.
442
+ *
443
+ * Uploading is round-trip bound, not bandwidth bound — a first import moved
444
+ * ~330 events/s sequentially, which is one 100-event batch per ~300ms of
445
+ * mostly waiting — so sending several at once divides the wall clock of a
446
+ * backfill by roughly that number. Order is deliberately NOT preserved
447
+ * across in-flight batches: the server derives a session's start from
448
+ * min(recorded start, earliest stored event), so a later batch landing first
449
+ * is corrected once the rest arrive. Within a batch the spool still yields
450
+ * oldest-first.
300
451
  *
301
- * Batches go out `upload.concurrency` at a time. Uploading is round-trip
302
- * bound, not bandwidth bound a first import moved ~330 events/s
303
- * sequentially, which is one 100-event batch per ~300ms of mostly waiting —
304
- * so sending several at once divides the wall clock of a backfill by roughly
305
- * that number.
452
+ * The failure policy runs ONCE per wave, on the collected outcomes:
453
+ * - any 413 -> batch size halves once
454
+ * - any retryable -> one backoff step; uploads are gated on nextUploadAt,
455
+ * never slept on, so tailing continues meanwhile
456
+ * - any poison -> strikes for those batches only
457
+ * - 401/403, quota, whole-batch schema rejection -> paused, nothing dropped
458
+ * - every batch ok -> counter reset, batch size creeps back up
306
459
  *
307
- * Order is deliberately NOT preserved across in-flight batches, and does not
308
- * need to be: the server derives a session's start from
309
- * min(recorded start, earliest stored event) and re-runs reconstruction after
310
- * every batch, so a later batch arriving first is corrected once the rest
311
- * land. Within a single batch the spool still yields oldest-first.
460
+ * Returns true when it stopped only because `maxWaves` ran out, i.e. there
461
+ * is more to send right now.
312
462
  */
313
- async flush() {
314
- for (;;) {
315
- const concurrency = Math.max(1, this.config.upload.concurrency);
463
+ async flush(maxWaves = Number.POSITIVE_INFINITY) {
464
+ const concurrency = Math.max(1, this.config.upload.concurrency);
465
+ for (let n = 0; n < maxWaves; n++) {
316
466
  // One peek for the whole wave. Peeking per batch would hand the same
317
- // rows to every request, because nothing is acked until they return
318
- // the same events would be uploaded `concurrency` times.
467
+ // rows to every request, because nothing is acked until they return.
319
468
  const wave = this.spool.peek(this.batchSize * concurrency);
320
469
  if (wave.length === 0) {
321
470
  this.uploadFailures = 0;
322
- return;
323
- }
324
- const batches = [];
325
- for (let i = 0; i < wave.length; i += this.batchSize) {
326
- batches.push(wave.slice(i, i + this.batchSize));
471
+ this.resume();
472
+ return false;
327
473
  }
474
+ const batches = chunkWave(wave, this.batchSize);
328
475
  // allSettled, not all: one failing batch must not abandon its siblings,
329
476
  // whose events are already accepted by the server.
330
- const outcomes = await Promise.allSettled(batches.map((b) => this.sendBatch(b)));
331
- // A false outcome means the failure policy already ran — batch size
332
- // halved, backoff slept, or a poison batch dropped — and the caller
333
- // should re-enter on the next tick rather than keep draining this wave
334
- // with settings that just changed.
335
- if (outcomes.some((o) => o.status === 'rejected' || o.value === false))
336
- return;
477
+ const settled = await Promise.allSettled(batches.map((b) => this.sendBatch(b)));
478
+ let allOk = true;
479
+ let tooLarge = false;
480
+ // `null as` keeps the declared union: a plain `= null` narrows to never below.
481
+ let retry = null;
482
+ let paused = null;
483
+ let accepted = 0;
484
+ let duplicates = 0;
485
+ const outcomes = settled.map((s) => s.status === 'fulfilled' ? s.value : { kind: 'retry', error: s.reason });
486
+ for (const [i, outcome] of outcomes.entries()) {
487
+ const ids = batches[i].map((b) => b.eventId);
488
+ if (outcome.kind === 'ok') {
489
+ // Duplicates are acknowledged too — the server already has them.
490
+ this.spool.ack(ids);
491
+ accepted += outcome.result.accepted;
492
+ duplicates += outcome.result.duplicates;
493
+ if (outcome.result.rejected.length > 0) {
494
+ log(`Server rejected ${outcome.result.rejected.length} events: ${outcome.result.rejected[0]?.reason ?? ''}`);
495
+ }
496
+ continue;
497
+ }
498
+ allOk = false;
499
+ if (outcome.kind === 'too_large')
500
+ tooLarge = true;
501
+ else if (outcome.kind === 'retry') {
502
+ retry = {
503
+ error: retry?.error ?? outcome.error,
504
+ retryAfterMs: Math.max(retry?.retryAfterMs ?? 0, outcome.retryAfterMs ?? 0),
505
+ };
506
+ }
507
+ else if (outcome.kind === 'poison') {
508
+ // The server will never accept these; count strikes so a poison
509
+ // batch cannot block the queue indefinitely.
510
+ const dropped = this.spool.fail(ids, this.config.upload.max_retries);
511
+ log(`Batch permanently rejected: ${errorMessage(outcome.error)}${dropped ? ` (dropped ${dropped})` : ''}`);
512
+ }
513
+ else
514
+ paused ??= outcome;
515
+ }
516
+ if (accepted + duplicates > 0)
517
+ log(`Uploaded ${accepted} events (${duplicates} duplicates)`);
518
+ if (tooLarge) {
519
+ // The batch is too big for the server, but the events are fine.
520
+ this.batchSize = Math.max(1, Math.floor(this.batchSize / 2));
521
+ log(`Server rejected the batch as too large — reducing batch size to ${this.batchSize}`);
522
+ }
523
+ if (paused) {
524
+ this.pause(paused);
525
+ return false;
526
+ }
527
+ if (retry !== null) {
528
+ this.uploadFailures += 1;
529
+ const wait = Math.max(backoffMs(this.uploadFailures), retry.retryAfterMs);
530
+ this.nextUploadAt = Date.now() + wait;
531
+ log(`Upload failed (attempt ${this.uploadFailures}), retrying in ${Math.round(wait / 1000)}s: ${errorMessage(retry.error)}`);
532
+ return false;
533
+ }
534
+ // Settings just changed (413) or strikes were counted: re-enter on the
535
+ // next tick rather than keep draining with the old shape.
536
+ if (!allOk)
537
+ return false;
538
+ this.uploadFailures = 0;
539
+ this.resume();
540
+ // Creep back up after a shrink so one huge session does not permanently
541
+ // halve throughput.
542
+ if (this.batchSize < this.maxBatchSize) {
543
+ this.batchSize = Math.min(this.maxBatchSize, this.batchSize * 2);
544
+ }
337
545
  }
546
+ return true;
338
547
  }
339
- /**
340
- * Sends one batch. Returns false when the wave should stop.
341
- *
342
- * Every failure branch is the same policy this had when batches went out one
343
- * at a time; only the `return` became `return false`.
344
- */
548
+ /** Sends one batch and reports what happened. Touches no shared state. */
345
549
  async sendBatch(batch) {
346
550
  try {
347
551
  const result = await this.client.sendBatch(batch.map((b) => b.event));
348
- // Duplicates are acknowledged too the server already has them.
349
- this.spool.ack(batch.map((b) => b.eventId));
350
- this.uploadFailures = 0;
351
- // Creep back up after a shrink so one huge session does not permanently
352
- // halve throughput.
353
- if (this.batchSize < this.config.upload.batch_size) {
354
- this.batchSize = Math.min(this.config.upload.batch_size, this.batchSize * 2);
355
- }
356
- if (result.rejected.length > 0) {
357
- log(`Server rejected ${result.rejected.length} events: ${result.rejected[0]?.reason ?? ''}`);
552
+ const nothingTaken = result.accepted === 0 && result.duplicates === 0 && result.rejected.length >= batch.length;
553
+ if (nothingTaken) {
554
+ // The server said 200 but kept nothing. Acking would delete telemetry
555
+ // it never stored: an over-quota org (retry next month, not never) or
556
+ // a collector whose schema the server no longer understands.
557
+ if (result.quota?.exceeded) {
558
+ return {
559
+ kind: 'paused',
560
+ reason: 'quota',
561
+ detail: `monthly event quota exceeded (${result.quota.used ?? '?'}/${result.quota.limit ?? '?'})`,
562
+ };
563
+ }
564
+ // One event the server would not take is that event's fault, not a
565
+ // collector-wide schema drift: strike it, do not pause the queue.
566
+ if (batch.length === 1)
567
+ return { kind: 'poison', error: new Error(result.rejected[0]?.reason ?? 'rejected') };
568
+ return {
569
+ kind: 'paused',
570
+ reason: 'schema',
571
+ detail: `server rejected every event (${result.rejected[0]?.reason ?? ''}) — collector ${VERSION} may be out of date`,
572
+ };
358
573
  }
359
- log(`Uploaded ${result.accepted} events (${result.duplicates} duplicates)`);
360
- return true;
574
+ return { kind: 'ok', result };
361
575
  }
362
576
  catch (error) {
363
- const status = error instanceof Error && 'status' in error ? Number(error.status) : 0;
364
- // 413: the batch is too big for the server, but the events are fine.
365
- // Halve and retry rather than treat real telemetry as poison.
366
- if (status === 413 && this.batchSize > 1) {
367
- this.batchSize = Math.max(1, Math.floor(this.batchSize / 2));
368
- log(`Server rejected the batch as too largereducing batch size to ${this.batchSize}`);
369
- return false;
370
- }
371
- const retryable = error instanceof Error && 'retryable' in error ? Boolean(error.retryable) : true;
372
- if (!retryable) {
373
- // The server will never accept these; count strikes so a poison
374
- // batch cannot block the queue indefinitely.
375
- const dropped = this.spool.fail(batch.map((b) => b.eventId), this.config.upload.max_retries);
376
- log(`Batch permanently rejected: ${errorMessage(error)}${dropped ? ` (dropped ${dropped})` : ''}`);
377
- return false;
378
- }
379
- this.uploadFailures += 1;
380
- const wait = backoffMs(this.uploadFailures);
381
- log(`Upload failed (attempt ${this.uploadFailures}), retrying in ${Math.round(wait / 1000)}s: ${errorMessage(error)}`);
382
- await sleep(wait);
383
- return false;
577
+ if (!(error instanceof ApiError))
578
+ return { kind: 'retry', error };
579
+ // The key is revoked or lacks ingest: the events are fine, the login is not.
580
+ if (error.status === 401 || error.status === 403)
581
+ return { kind: 'paused', reason: 'auth', detail: error.message };
582
+ // Too big for the server but the events are fine unless it is a
583
+ // single event, which then really is unacceptable.
584
+ if (error.status === 413)
585
+ return batch.length > 1 ? { kind: 'too_large' } : { kind: 'poison', error };
586
+ if (error.retryable)
587
+ return { kind: 'retry', error, retryAfterMs: error.retryAfterMs };
588
+ return { kind: 'poison', error };
589
+ }
590
+ }
591
+ /** Backs off and records why, once per reason, where `agentstrack status` can read it. */
592
+ pause(p) {
593
+ this.uploadFailures += 1;
594
+ this.nextUploadAt = Date.now() + backoffMs(this.uploadFailures);
595
+ if (this.pausedReason !== p.reason) {
596
+ this.pausedReason = p.reason;
597
+ log(`Uploads paused (${p.reason}): ${p.detail}`);
384
598
  }
599
+ this.spool.setMeta(UPLOAD_PAUSE_META, `${p.reason}: ${p.detail}`);
600
+ }
601
+ resume() {
602
+ if (!this.pausedReason)
603
+ return;
604
+ log(`Uploads resumed after ${this.pausedReason} pause`);
605
+ this.pausedReason = null;
606
+ this.spool.deleteMeta(UPLOAD_PAUSE_META);
385
607
  }
386
608
  async reportHealth() {
387
609
  if (!this.config.collector_id)
@@ -389,6 +611,7 @@ export class Collector {
389
611
  try {
390
612
  const agents = await Promise.all(this.adapters.map(async (a) => ({ agent: a.id, version: (await a.detect()).version })));
391
613
  await this.client.health({
614
+ ...machineInfo(),
392
615
  collector_id: this.config.collector_id,
393
616
  queue_depth: this.spool.depth(),
394
617
  version: VERSION,
@@ -403,8 +626,16 @@ export class Collector {
403
626
  queueDepth() {
404
627
  return this.spool.depth();
405
628
  }
629
+ /** Upload policy state, for tests and diagnostics. */
630
+ uploadState() {
631
+ return {
632
+ failures: this.uploadFailures,
633
+ batchSize: this.batchSize,
634
+ nextUploadAt: this.nextUploadAt,
635
+ paused: this.pausedReason,
636
+ };
637
+ }
406
638
  }
407
- export const VERSION = '0.1.0';
408
639
  function sleep(ms) {
409
640
  return new Promise((resolve) => setTimeout(resolve, ms));
410
641
  }