@llmaudit/logship 1.0.1 → 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LLM Audit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@llmaudit/logship",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Enterprise-grade, zero-dependency log shipping agent for @llmaudit. Lightweight, reliable, and performance-focused.",
5
+ "author": "LLM Audit Team",
6
+ "license": "MIT",
5
7
  "main": "src/index.js",
6
8
  "bin": {
7
9
  "logship": "bin/logship.js"
@@ -20,8 +22,5 @@
20
22
  "telemetry",
21
23
  "llmaudit",
22
24
  "monitoring"
23
- ],
24
- "author": "Antigravity",
25
- "license": "ISC",
26
- "type": "commonjs"
25
+ ]
27
26
  }
package/src/batcher.js CHANGED
@@ -1,10 +1,18 @@
1
- class Batcher {
1
+ const EventEmitter = require('events');
2
+
3
+ class Batcher extends EventEmitter {
2
4
  constructor(config, transport) {
5
+ super();
3
6
  this.config = config;
4
7
  this.transport = transport;
5
8
  this.queue = [];
6
9
  this.flushTimer = null;
7
10
  this.isFlushing = false;
11
+
12
+ // Backpressure limits
13
+ // If queue exceeds highWaterMark, we signal to stop reading
14
+ this.highWaterMark = this.config.maxBatchSize * 5;
15
+ this.isPaused = false;
8
16
  }
9
17
 
10
18
  start() {
@@ -25,6 +33,10 @@ class Batcher {
25
33
  return this.flush();
26
34
  }
27
35
 
36
+ isFull() {
37
+ return this.queue.length >= this.highWaterMark;
38
+ }
39
+
28
40
  /**
29
41
  * Adds a log line to the batch.
30
42
  * @param {string} line - The raw log line.
@@ -64,8 +76,11 @@ class Batcher {
64
76
  // For this implementation, we log the error. Retry logic is in Transport.
65
77
  } finally {
66
78
  this.isFlushing = false;
67
- // If queue filled up while flushing, trigger another flush immediately?
68
- // Maybe not immediately to avoid storm, wait for next tick or interval.
79
+
80
+ // Check if we can resume reading
81
+ if (this.queue.length < this.highWaterMark / 2) {
82
+ this.emit('drain');
83
+ }
69
84
  }
70
85
  }
71
86
  }
package/src/index.js CHANGED
@@ -12,9 +12,7 @@ class LogAgent {
12
12
  this.registry = new Registry();
13
13
  this.transport = new Transport(this.config);
14
14
  this.batcher = new Batcher(this.config, this.transport);
15
- this.watcher = new WatchManager(this.config.logFiles, this.registry, (line) => {
16
- this.batcher.push(line);
17
- });
15
+ this.watcher = new WatchManager(this.config.logFiles, this.registry, this.batcher);
18
16
  }
19
17
 
20
18
  init() {
package/src/registry.js CHANGED
@@ -6,6 +6,18 @@ class Registry {
6
6
  this.storagePath = storagePath || path.resolve(process.cwd(), '.agent-registry.json');
7
7
  this.data = {};
8
8
  this.saveTimer = null;
9
+
10
+ // Ensure we save on exit
11
+ process.on('exit', () => this.save());
12
+ process.on('SIGINT', () => {
13
+ this.save();
14
+ process.exit();
15
+ });
16
+ process.on('uncaughtException', (err) => {
17
+ console.error('Log Agent: Uncaught Exception', err);
18
+ this.save();
19
+ process.exit(1);
20
+ });
9
21
  }
10
22
 
11
23
  load() {
@@ -36,7 +48,7 @@ class Registry {
36
48
 
37
49
  scheduleSave() {
38
50
  if (this.saveTimer) return;
39
- this.saveTimer = setTimeout(() => this.save(), 1000); // Debounce saves
51
+ this.saveTimer = setTimeout(() => this.save(), 500); // Faster debounce (500ms)
40
52
  }
41
53
 
42
54
  save() {
package/src/watcher.js CHANGED
@@ -1,19 +1,31 @@
1
1
  const fs = require('fs');
2
2
 
3
3
  class FileWatcher {
4
- constructor(filepath, registry, onLogLine) {
4
+ constructor(filepath, registry, batcher) {
5
5
  this.filepath = filepath;
6
6
  this.registry = registry;
7
- this.onLogLine = onLogLine;
7
+ this.batcher = batcher; // Use Batcher instance directly (was onLogLine)
8
8
  this.currSize = 0;
9
9
  this.inode = null;
10
10
  this.signature = null;
11
11
  this.watcher = null;
12
12
  this.checkInterval = null;
13
13
  this.recreateTimer = null;
14
+ this.stream = null;
15
+ this.isProcessing = false;
16
+ this.debounceTimer = null;
17
+
18
+ // Listen for drain to resume reading if paused
19
+ this.batcher.on('drain', () => {
20
+ if (this.stream && this.stream.isPaused()) {
21
+ console.log(`Log Agent: Resuming read for ${this.filepath}`);
22
+ this.stream.resume();
23
+ }
24
+ });
14
25
  }
15
26
 
16
27
  start() {
28
+ this.stop(); // Prevent multiple instances
17
29
  this._initFile();
18
30
  }
19
31
 
@@ -30,6 +42,10 @@ class FileWatcher {
30
42
  clearTimeout(this.recreateTimer);
31
43
  this.recreateTimer = null;
32
44
  }
45
+ if (this.stream) {
46
+ this.stream.destroy();
47
+ this.stream = null;
48
+ }
33
49
  }
34
50
 
35
51
  _initFile() {
@@ -50,47 +66,125 @@ class FileWatcher {
50
66
  const savedState = this.registry.get(this.filepath);
51
67
 
52
68
  let isValid = false;
53
- if (savedState && savedState.inode === this.inode) {
54
- if (savedState.signature && savedState.signature !== signature) {
55
- // Signature mismatch means content changed (reuse)
56
- console.log(`Log Agent: Signature Mismatch! Resetting.`);
57
- isValid = false;
58
- } else if (stats.birthtimeMs && stats.birthtimeMs > savedState.lastUpdated) {
59
- console.log(`Log Agent: Newer Birthtime Detected. Resetting.`);
60
- isValid = false;
61
- } else {
69
+ if (savedState) {
70
+ // SIGNATURE-FIRST IDENTITY
71
+ // If the first 256 bytes match, it is the same content stream.
72
+ // We ignore Inode changes (common on Windows) and Birthtime (sensitive to edits).
73
+ if (savedState.signature && savedState.signature === signature) {
74
+ if (savedState.inode !== this.inode) {
75
+ console.log(`Log Agent: Inode changed for ${this.filepath} but Signature matches. Resuming.`);
76
+ }
62
77
  isValid = true;
78
+ } else if (savedState.inode === this.inode) {
79
+ // Same inode but signature changed? This is a rotation or total overwrite.
80
+ console.log(`Log Agent: Signature Mismatch for same Inode. Resetting.`);
81
+ isValid = false;
63
82
  }
64
83
  }
65
84
 
66
85
  if (isValid) {
67
- console.log(`Log Agent: Resuming ${this.filepath} from offset ${savedState.offset}`);
68
- this.currSize = savedState.offset;
69
-
70
- if (stats.size > this.currSize) {
71
- this._read(this.currSize, stats.size);
72
- this.currSize = stats.size;
73
- this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
74
- } else if (stats.size < this.currSize) {
75
- console.log('Log Agent: File truncated/rotated. Resetting.');
76
- this.currSize = 0;
77
- this._read(0, stats.size);
78
- this.currSize = stats.size;
79
- this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
86
+ if (savedState.offset > stats.size) {
87
+ console.log(`Log Agent: Saved offset ${savedState.offset} exceeds file size ${stats.size}. Truncated?`);
88
+ this._handleTruncation(stats);
89
+ return; // HandleTruncation will call _afterInit
90
+ } else {
91
+ this.currSize = savedState.offset;
92
+ console.log(`Log Agent: Resuming ${this.filepath} from offset ${this.currSize}`);
80
93
  }
94
+ this._afterInit(stats);
81
95
  } else {
82
- console.log(`Log Agent: New file/rotation/mismatch. Starting at ${stats.size}`);
83
- this.currSize = stats.size;
84
- this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
96
+ // Start from last 100k lines
97
+ const MAX_LINES = 100000;
98
+ this._findTailOffset(this.filepath, MAX_LINES, (offset) => {
99
+ this.currSize = offset;
100
+ console.log(`Log Agent: New file. Starting at ${this.currSize} (Tail: last ${MAX_LINES} lines)`);
101
+
102
+ // Force update registry so we don't re-read if crashed immediately
103
+ this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
104
+ this._afterInit(stats);
105
+ });
85
106
  }
107
+ });
108
+ });
109
+ }
86
110
 
87
- this._watch();
111
+ _afterInit(stats) {
112
+ // If file is ahead, read catchup
113
+ if (stats.size > this.currSize) {
114
+ this._read(this.currSize, stats.size);
115
+ this.currSize = stats.size;
116
+ this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
117
+ } else if (stats.size < this.currSize) {
118
+ this._handleTruncation(stats);
119
+ return; // _handleTruncation will re-trigger what's needed
120
+ }
121
+
122
+ this._watch();
123
+ }
124
+
125
+ _findTailOffset(filepath, maxLines, callback) {
126
+ fs.open(filepath, 'r', (err, fd) => {
127
+ if (err) return callback(0);
128
+
129
+ fs.fstat(fd, (err, stats) => {
130
+ if (err) {
131
+ fs.close(fd, () => { });
132
+ return callback(0);
133
+ }
134
+
135
+ let size = stats.size;
136
+ if (size === 0) {
137
+ fs.close(fd, () => { });
138
+ return callback(0);
139
+ }
140
+
141
+ let linesCount = 0;
142
+ let offset = size;
143
+ const bufferSize = 64 * 1024; // 64KB
144
+ const buffer = Buffer.alloc(bufferSize);
145
+
146
+ const readNextChunk = () => {
147
+ const toRead = Math.min(bufferSize, offset);
148
+ const readAt = offset - toRead;
149
+
150
+ fs.read(fd, buffer, 0, toRead, readAt, (err, bytesRead) => {
151
+ if (err || bytesRead === 0) {
152
+ fs.close(fd, () => { });
153
+ return callback(0);
154
+ }
155
+
156
+ // Scan backwards for newlines
157
+ for (let i = bytesRead - 1; i >= 0; i--) {
158
+ if (buffer[i] === 10) { // \n
159
+ linesCount++;
160
+ if (linesCount > maxLines) {
161
+ // Found the line! Start offset is right after this \n
162
+ const foundOffset = readAt + i + 1;
163
+ fs.close(fd, () => { });
164
+ return callback(foundOffset);
165
+ }
166
+ }
167
+ }
168
+
169
+ offset -= bytesRead;
170
+ if (offset > 0) {
171
+ readNextChunk();
172
+ } else {
173
+ // Reached start of file without hitting maxLines
174
+ fs.close(fd, () => { });
175
+ callback(0);
176
+ }
177
+ });
178
+ };
179
+
180
+ readNextChunk();
88
181
  });
89
182
  });
90
183
  }
91
184
 
92
185
  _readSignature(filepath, callback) {
93
- const stream = fs.createReadStream(filepath, { start: 0, end: 63 });
186
+ // Increased to 256 bytes to avoid collisions with long boilerplate headers
187
+ const stream = fs.createReadStream(filepath, { start: 0, end: 255 });
94
188
  let buf = Buffer.alloc(0);
95
189
  stream.on('data', chunk => {
96
190
  buf = Buffer.concat([buf, chunk]);
@@ -104,24 +198,25 @@ class FileWatcher {
104
198
  _watch() {
105
199
  try {
106
200
  this.watcher = fs.watch(this.filepath, (eventType, filename) => {
107
- if (eventType === 'rename') {
108
- fs.stat(this.filepath, (err, stats) => {
109
- if (err && err.code === 'ENOENT') {
110
- this.stop();
111
- this.recreateTimer = setTimeout(() => this.start(), 100);
112
- } else {
113
- if (stats.ino !== this.inode) {
114
- this.inode = stats.ino;
115
- this.currSize = 0;
116
- this._processChange();
201
+ // Debounce rapidly firing events (common in high-traffic writes)
202
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
203
+
204
+ this.debounceTimer = setTimeout(() => {
205
+ if (eventType === 'rename') {
206
+ // On Windows, rename can mean a safe-write or a rotation.
207
+ fs.stat(this.filepath, (err, stats) => {
208
+ if (err && err.code === 'ENOENT') {
209
+ console.log(`Log Agent: ${this.filepath} moved/deleted. Waiting for recreation...`);
210
+ this.stop();
211
+ this.recreateTimer = setTimeout(() => this.start(), 1000);
117
212
  } else {
118
- this._processChange();
213
+ this._initFile();
119
214
  }
120
- }
121
- });
122
- } else {
123
- this._processChange();
124
- }
215
+ });
216
+ } else {
217
+ this._processChange();
218
+ }
219
+ }, 50); // 50ms pulse to group rapid writes
125
220
  });
126
221
  } catch (e) {
127
222
  console.error(`Log Agent: Watch failed for ${this.filepath}`, e);
@@ -129,18 +224,40 @@ class FileWatcher {
129
224
  }
130
225
 
131
226
  _processChange() {
227
+ if (this.isProcessing) return; // Prevent overlapping stat/read calls
228
+ this.isProcessing = true;
229
+
132
230
  fs.stat(this.filepath, (err, stats) => {
133
- if (err) return;
231
+ if (err) {
232
+ this.isProcessing = false;
233
+ return;
234
+ }
134
235
 
135
236
  if (stats.size > this.currSize) {
136
237
  this._read(this.currSize, stats.size);
137
238
  this.currSize = stats.size;
138
239
  this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
139
240
  } else if (stats.size < this.currSize) {
140
- this.currSize = 0;
141
- this._read(0, stats.size);
142
- this.currSize = stats.size;
143
- this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
241
+ this._handleTruncation(stats);
242
+ }
243
+
244
+ this.isProcessing = false;
245
+ });
246
+ }
247
+
248
+ _handleTruncation(stats) {
249
+ console.log(`Log Agent: File ${this.filepath} truncated (${this.currSize} -> ${stats.size}). Re-tailing last 100k lines.`);
250
+ const MAX_LINES = 100000;
251
+ this._findTailOffset(this.filepath, MAX_LINES, (offset) => {
252
+ this.currSize = offset;
253
+ console.log(`Log Agent: Smart Resume after truncation at offset ${this.currSize}`);
254
+ this._read(this.currSize, stats.size);
255
+ this.currSize = stats.size;
256
+ this.registry.set(this.filepath, this.currSize, this.inode, this.signature);
257
+
258
+ // If this was called during init, we need to start watching
259
+ if (!this.watcher && !this.checkInterval) {
260
+ this._watch();
144
261
  }
145
262
  });
146
263
  }
@@ -149,42 +266,54 @@ class FileWatcher {
149
266
  if (start >= end) return;
150
267
  if (start < 0) start = 0;
151
268
 
152
- const stream = fs.createReadStream(this.filepath, {
269
+ // Clean up previous stream if somehow open (though _read is usually sequential/triggered by serial events)
270
+ if (this.stream) {
271
+ this.stream.destroy();
272
+ }
273
+
274
+ this.stream = fs.createReadStream(this.filepath, {
153
275
  start: start,
154
276
  end: end - 1,
155
277
  encoding: 'utf8'
156
278
  });
157
279
 
158
280
  let buffer = '';
159
- stream.on('data', (chunk) => {
281
+ this.stream.on('data', (chunk) => {
282
+ // Check Backpressure
283
+ if (this.batcher.isFull()) {
284
+ console.log(`Log Agent: Queue full, pausing read on ${this.filepath}`);
285
+ this.stream.pause();
286
+ }
287
+
160
288
  buffer += chunk;
161
289
  const lines = buffer.split('\n');
162
- buffer = lines.pop();
290
+ buffer = lines.pop(); // Keep partial line
163
291
 
164
292
  for (const line of lines) {
165
- if (line.trim()) this.onLogLine(line);
293
+ if (line.trim()) this.batcher.push(line);
166
294
  }
167
295
  });
168
296
 
169
- stream.on('end', () => {
297
+ this.stream.on('end', () => {
170
298
  if (buffer && buffer.trim()) {
171
- this.onLogLine(buffer);
299
+ this.batcher.push(buffer);
172
300
  }
301
+ this.stream = null;
173
302
  });
174
303
  }
175
304
  }
176
305
 
177
306
  class WatchManager {
178
- constructor(logFiles, registry, onLogLine) {
307
+ constructor(logFiles, registry, batcher) {
179
308
  this.logFiles = logFiles;
180
309
  this.registry = registry;
181
- this.onLogLine = onLogLine;
310
+ this.batcher = batcher; // Pass batcher instead of callback
182
311
  this.watchers = [];
183
312
  }
184
313
 
185
314
  start() {
186
315
  this.logFiles.forEach(file => {
187
- const watcher = new FileWatcher(file, this.registry, this.onLogLine);
316
+ const watcher = new FileWatcher(file, this.registry, this.batcher);
188
317
  watcher.start();
189
318
  this.watchers.push(watcher);
190
319
  });