@agentstrack/collector 0.2.0 → 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 +177 -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 +19 -7
  10. package/dist/adapters/opencode.js +88 -29
  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 +165 -34
  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 +2 -1
  20. package/dist/config.js +31 -5
  21. package/dist/config.js.map +1 -1
  22. package/dist/daemon.d.ts +73 -21
  23. package/dist/daemon.js +358 -121
  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 {
@@ -26,7 +36,13 @@ export function log(message) {
26
36
  }
27
37
  }
28
38
  export function buildAdapters(config) {
29
- const all = [new ClaudeCodeAdapter(), new CodexAdapter(), new OpenCodeAdapter()];
39
+ // OpenCode reads a database, not files, so max_age_days reaches it through
40
+ // its constructor rather than through the transcript walk.
41
+ const all = [
42
+ new ClaudeCodeAdapter(),
43
+ new CodexAdapter(),
44
+ new OpenCodeAdapter(undefined, config.tracking.max_age_days),
45
+ ];
30
46
  return all.filter((a) => config.tracking.agents.includes(a.id));
31
47
  }
32
48
  /**
@@ -85,6 +101,17 @@ export function listTranscripts(dir, maxAgeDays = 7) {
85
101
  walk(dir, 0);
86
102
  return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
87
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';
88
115
  export class Collector {
89
116
  config;
90
117
  spool;
@@ -93,6 +120,7 @@ export class Collector {
93
120
  /** Null when git metadata is switched off — then we never shell out to git. */
94
121
  commitWatcher;
95
122
  serverConfig = null;
123
+ orgRules = [];
96
124
  /**
97
125
  * When this collector started, and therefore the earliest event it can
98
126
  * honestly attribute to an account.
@@ -106,24 +134,34 @@ export class Collector {
106
134
  */
107
135
  liveSinceMs = Date.now();
108
136
  uploadFailures = 0;
109
- /** Shrinks on 413, recovers on success. Never below 1. */
137
+ /** Shrinks on 413, recovers on success. Never below 1, never above maxBatchSize. */
110
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();
111
148
  running = false;
112
- constructor(config) {
149
+ constructor(config, deps = {}) {
113
150
  this.config = config;
114
151
  if (!config.api_key)
115
152
  throw new Error('Not logged in. Run: agentstrack login <api-key>');
116
- this.spool = new Spool(SPOOL_PATH);
117
- 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 });
118
155
  this.adapters = buildAdapters(config);
119
156
  this.commitWatcher = config.tracking.git_metadata ? new GitCommitWatcher() : null;
120
157
  this.batchSize = config.upload.batch_size;
158
+ this.maxBatchSize = config.upload.batch_size;
121
159
  }
122
160
  async start() {
123
161
  this.running = true;
124
162
  await this.ensureRegistered();
125
163
  await this.refreshServerConfig();
126
- log(`Collector started — agents: ${this.adapters.map((a) => a.id).join(', ')}`);
164
+ log(`Collector ${VERSION} started — agents: ${this.adapters.map((a) => a.id).join(', ')}`);
127
165
  const scanInterval = 5_000;
128
166
  const uploadInterval = this.config.upload.interval_seconds * 1000;
129
167
  let lastUpload = 0;
@@ -136,19 +174,29 @@ export class Collector {
136
174
  log(`Scan error: ${errorMessage(error)}`);
137
175
  }
138
176
  const now = Date.now();
139
- if (now - lastUpload >= uploadInterval || this.spool.depth() >= this.batchSize) {
140
- lastUpload = now;
141
- 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
+ }
142
187
  }
143
- if (now - lastHealth >= 60_000) {
144
- lastHealth = now;
145
- await this.reportHealth();
188
+ catch (error) {
189
+ log(`Upload error: ${errorMessage(error)}`);
146
190
  }
147
- await sleep(scanInterval);
191
+ // A backlog alternates scan and flush without the 5s pause between them.
192
+ await sleep(more ? 0 : scanInterval);
148
193
  }
149
194
  }
150
195
  stop() {
151
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');
152
200
  this.spool.close();
153
201
  }
154
202
  /** Registers this device once and remembers the id. */
@@ -157,9 +205,7 @@ export class Collector {
157
205
  return;
158
206
  const agents = await Promise.all(this.adapters.map(async (a) => ({ agent: a.id, version: (await a.detect()).version })));
159
207
  const result = await this.client.registerCollector({
160
- hostname: hostname(),
161
- os: platform(),
162
- arch: arch(),
208
+ ...machineInfo(),
163
209
  version: VERSION,
164
210
  // Report what this device enforces, so a session records the mode that
165
211
  // actually applied rather than the org default.
@@ -173,6 +219,8 @@ export class Collector {
173
219
  async refreshServerConfig() {
174
220
  try {
175
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);
176
224
  // The org sets a ceiling; a stricter local mode is honoured, a looser one
177
225
  // is not. Same rule as `login`, shared so the two cannot drift.
178
226
  const effective = clampPrivacyMode(this.config.privacy.mode, this.serverConfig.privacy_mode);
@@ -180,6 +228,13 @@ export class Collector {
180
228
  log(`Local privacy mode '${this.config.privacy.mode}' exceeds org policy '${this.serverConfig.privacy_mode}' — using org policy`);
181
229
  this.config = { ...this.config, privacy: { ...this.config.privacy, mode: effective } };
182
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
+ }
183
238
  }
184
239
  catch (error) {
185
240
  log(`Could not fetch server config, using local defaults: ${errorMessage(error)}`);
@@ -190,6 +245,10 @@ export class Collector {
190
245
  const collectorId = this.config.collector_id;
191
246
  if (!collectorId)
192
247
  return;
248
+ this.repoCache.clear();
249
+ let queued = 0;
250
+ let files = 0;
251
+ let skipped = 0;
193
252
  for (const adapter of this.adapters) {
194
253
  const detection = await adapter.detect();
195
254
  if (!detection.installed)
@@ -198,35 +257,44 @@ export class Collector {
198
257
  // accounts since the last scan.
199
258
  const account = adapter.account?.();
200
259
  for (const watchPath of detection.watchPaths) {
201
- for (const file of listTranscripts(watchPath)) {
202
- const { lines } = await tailFile(file, this.spool);
203
- if (lines.length === 0)
204
- continue;
205
- const normalized = [];
206
- for (const line of lines) {
207
- try {
208
- normalized.push(...adapter.normalize(line, { collectorId, sourceFile: file }));
209
- }
210
- catch (error) {
211
- // A parser bug on one line must not stop the whole file.
212
- log(`normalize error in ${file}: ${errorMessage(error)}`);
213
- }
260
+ for (const file of listTranscripts(watchPath, this.config.tracking.max_age_days)) {
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)}`);
214
290
  }
215
- this.commitWatcher?.observe(normalized);
216
- this.enqueue(normalized, collectorId, account);
217
291
  }
218
292
  }
219
293
  // Database-backed agents have no lines to tail; they hand us events on
220
294
  // the same cycle, under the same gating and the same privacy pipeline.
221
295
  if (adapter.poll) {
222
296
  try {
223
- const polled = await adapter.poll({
224
- collectorId,
225
- getMeta: (key) => this.spool.getMeta(key),
226
- setMeta: (key, value) => this.spool.setMeta(key, value),
227
- });
228
- this.commitWatcher?.observe(polled);
229
- this.enqueue(polled, collectorId, account);
297
+ queued += await this.pollAdapter(adapter.poll.bind(adapter), collectorId, account);
230
298
  }
231
299
  catch (error) {
232
300
  log(`poll error in ${adapter.id}: ${errorMessage(error)}`);
@@ -238,21 +306,82 @@ export class Collector {
238
306
  // actually touched, once per scan cycle.
239
307
  if (this.commitWatcher) {
240
308
  try {
241
- this.enqueue(await this.commitWatcher.poll(), collectorId);
309
+ queued += this.enqueue(await this.commitWatcher.poll(), collectorId);
242
310
  }
243
311
  catch (error) {
244
312
  log(`Commit scan error: ${errorMessage(error)}`);
245
313
  }
246
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`);
247
320
  }
248
- 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) {
249
378
  const envelopes = [];
250
- for (const item of normalized) {
379
+ for (const item of items) {
251
380
  const cwd = item.cwd;
252
381
  // An excluded project never produces an event at all.
253
382
  if (cwd && isExcluded(cwd, this.config.privacy.excluded_projects))
254
383
  continue;
255
- 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;
256
385
  const payload = repo
257
386
  ? { ...item.event.payload, repo: { ...repo, ...(item.event.payload['repo'] ?? {}) } }
258
387
  : { ...item.event.payload };
@@ -273,109 +402,208 @@ export class Collector {
273
402
  const envelope = {
274
403
  ...item.event,
275
404
  payload,
276
- event_id: randomUUID(),
405
+ event_id: item.eventId ?? randomUUID(),
277
406
  schema_version: SCHEMA_VERSION,
278
407
  collector_id: collectorId,
279
408
  };
280
409
  const { event } = applyPrivacy(envelope, {
281
410
  config: this.config,
282
411
  projectRoot: repo?.project_path,
283
- orgRules: this.serverConfig?.redaction_rules,
412
+ compiledRules: this.orgRules,
284
413
  });
285
414
  envelopes.push(event);
415
+ this.trackSession(event);
286
416
  }
287
- const written = this.spool.enqueue(envelopes);
288
- if (written > 0)
289
- 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
+ });
290
439
  }
291
- /** Drains the spool, oldest first, until it is empty or the server pushes back. */
292
440
  /**
293
- * 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.
294
451
  *
295
- * Batches go out `upload.concurrency` at a time. Uploading is round-trip
296
- * bound, not bandwidth bound a first import moved ~330 events/s
297
- * sequentially, which is one 100-event batch per ~300ms of mostly waiting —
298
- * so sending several at once divides the wall clock of a backfill by roughly
299
- * 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
300
459
  *
301
- * Order is deliberately NOT preserved across in-flight batches, and does not
302
- * need to be: the server derives a session's start from
303
- * min(recorded start, earliest stored event) and re-runs reconstruction after
304
- * every batch, so a later batch arriving first is corrected once the rest
305
- * 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.
306
462
  */
307
- async flush() {
308
- for (;;) {
309
- 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++) {
310
466
  // One peek for the whole wave. Peeking per batch would hand the same
311
- // rows to every request, because nothing is acked until they return
312
- // the same events would be uploaded `concurrency` times.
467
+ // rows to every request, because nothing is acked until they return.
313
468
  const wave = this.spool.peek(this.batchSize * concurrency);
314
469
  if (wave.length === 0) {
315
470
  this.uploadFailures = 0;
316
- return;
317
- }
318
- const batches = [];
319
- for (let i = 0; i < wave.length; i += this.batchSize) {
320
- batches.push(wave.slice(i, i + this.batchSize));
471
+ this.resume();
472
+ return false;
321
473
  }
474
+ const batches = chunkWave(wave, this.batchSize);
322
475
  // allSettled, not all: one failing batch must not abandon its siblings,
323
476
  // whose events are already accepted by the server.
324
- const outcomes = await Promise.allSettled(batches.map((b) => this.sendBatch(b)));
325
- // A false outcome means the failure policy already ran — batch size
326
- // halved, backoff slept, or a poison batch dropped — and the caller
327
- // should re-enter on the next tick rather than keep draining this wave
328
- // with settings that just changed.
329
- if (outcomes.some((o) => o.status === 'rejected' || o.value === false))
330
- 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
+ }
331
545
  }
546
+ return true;
332
547
  }
333
- /**
334
- * Sends one batch. Returns false when the wave should stop.
335
- *
336
- * Every failure branch is the same policy this had when batches went out one
337
- * at a time; only the `return` became `return false`.
338
- */
548
+ /** Sends one batch and reports what happened. Touches no shared state. */
339
549
  async sendBatch(batch) {
340
550
  try {
341
551
  const result = await this.client.sendBatch(batch.map((b) => b.event));
342
- // Duplicates are acknowledged too the server already has them.
343
- this.spool.ack(batch.map((b) => b.eventId));
344
- this.uploadFailures = 0;
345
- // Creep back up after a shrink so one huge session does not permanently
346
- // halve throughput.
347
- if (this.batchSize < this.config.upload.batch_size) {
348
- this.batchSize = Math.min(this.config.upload.batch_size, this.batchSize * 2);
349
- }
350
- if (result.rejected.length > 0) {
351
- 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
+ };
352
573
  }
353
- log(`Uploaded ${result.accepted} events (${result.duplicates} duplicates)`);
354
- return true;
574
+ return { kind: 'ok', result };
355
575
  }
356
576
  catch (error) {
357
- const status = error instanceof Error && 'status' in error ? Number(error.status) : 0;
358
- // 413: the batch is too big for the server, but the events are fine.
359
- // Halve and retry rather than treat real telemetry as poison.
360
- if (status === 413 && this.batchSize > 1) {
361
- this.batchSize = Math.max(1, Math.floor(this.batchSize / 2));
362
- log(`Server rejected the batch as too largereducing batch size to ${this.batchSize}`);
363
- return false;
364
- }
365
- const retryable = error instanceof Error && 'retryable' in error ? Boolean(error.retryable) : true;
366
- if (!retryable) {
367
- // The server will never accept these; count strikes so a poison
368
- // batch cannot block the queue indefinitely.
369
- const dropped = this.spool.fail(batch.map((b) => b.eventId), this.config.upload.max_retries);
370
- log(`Batch permanently rejected: ${errorMessage(error)}${dropped ? ` (dropped ${dropped})` : ''}`);
371
- return false;
372
- }
373
- this.uploadFailures += 1;
374
- const wait = backoffMs(this.uploadFailures);
375
- log(`Upload failed (attempt ${this.uploadFailures}), retrying in ${Math.round(wait / 1000)}s: ${errorMessage(error)}`);
376
- await sleep(wait);
377
- 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}`);
378
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);
379
607
  }
380
608
  async reportHealth() {
381
609
  if (!this.config.collector_id)
@@ -383,6 +611,7 @@ export class Collector {
383
611
  try {
384
612
  const agents = await Promise.all(this.adapters.map(async (a) => ({ agent: a.id, version: (await a.detect()).version })));
385
613
  await this.client.health({
614
+ ...machineInfo(),
386
615
  collector_id: this.config.collector_id,
387
616
  queue_depth: this.spool.depth(),
388
617
  version: VERSION,
@@ -397,8 +626,16 @@ export class Collector {
397
626
  queueDepth() {
398
627
  return this.spool.depth();
399
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
+ }
400
638
  }
401
- export const VERSION = '0.1.0';
402
639
  function sleep(ms) {
403
640
  return new Promise((resolve) => setTimeout(resolve, ms));
404
641
  }