@agentstrack/collector 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +209 -1
- package/README.md +114 -38
- package/dist/adapters/claude.d.ts +18 -0
- package/dist/adapters/claude.js +153 -45
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts +15 -1
- package/dist/adapters/codex.js +87 -34
- package/dist/adapters/codex.js.map +1 -1
- package/dist/adapters/opencode.d.ts +17 -6
- package/dist/adapters/opencode.js +72 -26
- package/dist/adapters/opencode.js.map +1 -1
- package/dist/adapters/types.d.ts +16 -0
- package/dist/adapters/types.js +61 -0
- package/dist/adapters/types.js.map +1 -1
- package/dist/cli.js +164 -33
- package/dist/cli.js.map +1 -1
- package/dist/commands/service.js +40 -10
- package/dist/commands/service.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +18 -5
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +73 -21
- package/dist/daemon.js +350 -119
- package/dist/daemon.js.map +1 -1
- package/dist/git/commits.d.ts +7 -1
- package/dist/git/commits.js +36 -17
- package/dist/git/commits.js.map +1 -1
- package/dist/git/repo.d.ts +13 -4
- package/dist/git/repo.js +34 -20
- package/dist/git/repo.js.map +1 -1
- package/dist/machine.d.ts +27 -0
- package/dist/machine.js +46 -0
- package/dist/machine.js.map +1 -0
- package/dist/privacy/pipeline.d.ts +6 -0
- package/dist/privacy/pipeline.js +41 -7
- package/dist/privacy/pipeline.js.map +1 -1
- package/dist/privacy/redact.d.ts +15 -2
- package/dist/privacy/redact.js +45 -6
- package/dist/privacy/redact.js.map +1 -1
- package/dist/queue/event-id.d.ts +9 -0
- package/dist/queue/event-id.js +15 -0
- package/dist/queue/event-id.js.map +1 -0
- package/dist/queue/spool.d.ts +24 -5
- package/dist/queue/spool.js +89 -33
- package/dist/queue/spool.js.map +1 -1
- package/dist/queue/tailer.d.ts +27 -4
- package/dist/queue/tailer.js +89 -28
- package/dist/queue/tailer.js.map +1 -1
- package/dist/sessions/title.d.ts +14 -2
- package/dist/sessions/title.js +18 -6
- package/dist/sessions/title.js.map +1 -1
- package/dist/transport/client.d.ts +37 -13
- package/dist/transport/client.js +50 -3
- package/dist/transport/client.js.map +1 -1
- 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 {
|
|
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 {
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
await this.reportHealth();
|
|
188
|
+
catch (error) {
|
|
189
|
+
log(`Upload error: ${errorMessage(error)}`);
|
|
152
190
|
}
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
412
|
+
compiledRules: this.orgRules,
|
|
290
413
|
});
|
|
291
414
|
envelopes.push(event);
|
|
415
|
+
this.trackSession(event);
|
|
292
416
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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
|
-
*
|
|
308
|
-
*
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
return true;
|
|
574
|
+
return { kind: 'ok', result };
|
|
361
575
|
}
|
|
362
576
|
catch (error) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
if (status ===
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
}
|