@llmaudit/logship 1.0.0 → 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 +21 -0
- package/package.json +4 -6
- package/src/batcher.js +18 -3
- package/src/index.js +1 -3
- package/src/registry.js +13 -1
- package/src/watcher.js +188 -59
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.
|
|
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"
|
|
@@ -19,10 +21,6 @@
|
|
|
19
21
|
"agent",
|
|
20
22
|
"telemetry",
|
|
21
23
|
"llmaudit",
|
|
22
|
-
"loki",
|
|
23
24
|
"monitoring"
|
|
24
|
-
]
|
|
25
|
-
"author": "Antigravity",
|
|
26
|
-
"license": "ISC",
|
|
27
|
-
"type": "commonjs"
|
|
25
|
+
]
|
|
28
26
|
}
|
package/src/batcher.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
68
|
-
//
|
|
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,
|
|
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(),
|
|
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,
|
|
4
|
+
constructor(filepath, registry, batcher) {
|
|
5
5
|
this.filepath = filepath;
|
|
6
6
|
this.registry = registry;
|
|
7
|
-
this.
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.currSize =
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
this.
|
|
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.
|
|
213
|
+
this._initFile();
|
|
119
214
|
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
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)
|
|
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.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
307
|
+
constructor(logFiles, registry, batcher) {
|
|
179
308
|
this.logFiles = logFiles;
|
|
180
309
|
this.registry = registry;
|
|
181
|
-
this.
|
|
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.
|
|
316
|
+
const watcher = new FileWatcher(file, this.registry, this.batcher);
|
|
188
317
|
watcher.start();
|
|
189
318
|
this.watchers.push(watcher);
|
|
190
319
|
});
|