@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.
- package/CHANGELOG.md +177 -1
- package/README.md +78 -28
- 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 +81 -33
- package/dist/adapters/codex.js.map +1 -1
- package/dist/adapters/opencode.d.ts +19 -7
- package/dist/adapters/opencode.js +88 -29
- 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 +165 -34
- 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 +2 -1
- package/dist/config.js +31 -5
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +73 -21
- package/dist/daemon.js +358 -121
- 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 +3 -0
- package/dist/privacy/pipeline.js +4 -2
- package/dist/privacy/pipeline.js.map +1 -1
- package/dist/privacy/redact.d.ts +10 -1
- package/dist/privacy/redact.js +29 -3
- 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/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 {
|
|
@@ -26,7 +36,13 @@ export function log(message) {
|
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
export function buildAdapters(config) {
|
|
29
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
await this.reportHealth();
|
|
188
|
+
catch (error) {
|
|
189
|
+
log(`Upload error: ${errorMessage(error)}`);
|
|
146
190
|
}
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
412
|
+
compiledRules: this.orgRules,
|
|
284
413
|
});
|
|
285
414
|
envelopes.push(event);
|
|
415
|
+
this.trackSession(event);
|
|
286
416
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
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
|
-
*
|
|
302
|
-
*
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
return true;
|
|
574
|
+
return { kind: 'ok', result };
|
|
355
575
|
}
|
|
356
576
|
catch (error) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
if (status ===
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
}
|