@devms/livetail 0.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/README.md ADDED
@@ -0,0 +1,511 @@
1
+ # @devms/livetail
2
+
3
+ NestJS WebSocket module for **real-time log streaming**. Drop it into any NestJS application to broadcast structured logs to connected clients via Socket.IO — like `tail -f` for your application logs.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @devms/livetail
11
+ # or
12
+ npm install @devms/livetail
13
+ ```
14
+
15
+ **Peer dependencies** (install if not already present):
16
+
17
+ ```bash
18
+ pnpm add @nestjs/websockets @nestjs/platform-socket.io
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Quick Start
24
+
25
+ ### 1. Register the module
26
+
27
+ ```typescript
28
+ // app.module.ts
29
+ import { LiveTailModule } from '@devms/livetail';
30
+
31
+ @Module({
32
+ imports: [
33
+ LiveTailModule.register(),
34
+ ],
35
+ })
36
+ export class AppModule {}
37
+ ```
38
+
39
+ ### 2. Broadcast logs from your service
40
+
41
+ ```typescript
42
+ import { LiveTailService } from '@devms/livetail';
43
+
44
+ @Injectable()
45
+ export class LogIngestionService {
46
+ constructor(private readonly liveTail: LiveTailService) {}
47
+
48
+ async ingestLog(log: CreateLogDto) {
49
+ // Save to database
50
+ await this.db.logs.create({ data: log });
51
+
52
+ // Broadcast to live tail clients
53
+ this.liveTail.broadcast({
54
+ environmentId: log.environmentId,
55
+ level: log.level,
56
+ category: log.category,
57
+ action: log.action,
58
+ message: log.message,
59
+ metadata: log.metadata,
60
+ userId: log.userId,
61
+ duration: log.duration,
62
+ tags: log.tags,
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+ }
66
+ }
67
+ ```
68
+
69
+ ### 3. Connect from the browser
70
+
71
+ ```typescript
72
+ import { io } from 'socket.io-client';
73
+
74
+ const socket = io('http://localhost:3000/live-tail');
75
+
76
+ socket.on('connected', ({ clientId }) => {
77
+ console.log('Connected:', clientId);
78
+
79
+ // Subscribe with filters
80
+ socket.emit('subscribe', {
81
+ environmentId: 'env-abc-123',
82
+ level: ['error', 'fatal'],
83
+ });
84
+ });
85
+
86
+ socket.on('log', (log) => {
87
+ console.log(`[${log.level}] ${log.category}/${log.action}: ${log.message}`);
88
+ });
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Configuration
94
+
95
+ ### Static config
96
+
97
+ ```typescript
98
+ LiveTailModule.register({
99
+ namespace: '/live-tail', // WebSocket namespace (default: '/live-tail')
100
+ cors: '*', // CORS origin (default: '*')
101
+ maxClients: 100, // Max simultaneous connections, 0 = unlimited (default: 0)
102
+ pingInterval: 25000, // Ping interval in ms (default: 25000)
103
+ pingTimeout: 20000, // Ping timeout in ms (default: 20000)
104
+ disabled: false, // Disable the gateway entirely (default: false)
105
+ })
106
+ ```
107
+
108
+ ### Async config (with ConfigService)
109
+
110
+ ```typescript
111
+ LiveTailModule.registerAsync({
112
+ inject: [ConfigService],
113
+ useFactory: (config: ConfigService) => ({
114
+ cors: config.get('LIVETAIL_CORS_ORIGIN', '*'),
115
+ maxClients: parseInt(config.get('LIVETAIL_MAX_CLIENTS', '0')),
116
+ disabled: config.get('LIVETAIL_DISABLED') === 'true',
117
+ }),
118
+ })
119
+ ```
120
+
121
+ ### Environment-specific usage
122
+
123
+ Disable live tail in environments where it's not needed:
124
+
125
+ ```typescript
126
+ LiveTailModule.register({
127
+ disabled: process.env.NODE_ENV === 'test',
128
+ })
129
+ ```
130
+
131
+ ### Configuration options
132
+
133
+ | Option | Type | Default | Description |
134
+ |----------------|-------------------------------|----------------|----------------------------------------------------------------|
135
+ | `namespace` | `string` | `'/live-tail'` | WebSocket namespace path |
136
+ | `cors` | `string \| string[] \| bool` | `'*'` | CORS origin for WebSocket connections |
137
+ | `maxClients` | `number` | `0` | Max simultaneous clients (0 = unlimited) |
138
+ | `pingInterval` | `number` | `25000` | Socket.IO ping interval in ms |
139
+ | `pingTimeout` | `number` | `20000` | Socket.IO ping timeout in ms |
140
+ | `disabled` | `boolean` | `false` | Disable the gateway (service still injectable but no-ops) |
141
+
142
+ ---
143
+
144
+ ## Environment Context (Enrichment)
145
+
146
+ When broadcasting logs, you can pass an environment context to enrich each log event with organization, application, and environment names. This allows the UI to display human-readable labels without additional API calls.
147
+
148
+ ```typescript
149
+ import { LiveTailService, LiveTailEnvContext } from '@devms/livetail';
150
+
151
+ @Injectable()
152
+ export class AppLogService {
153
+ constructor(private readonly liveTail: LiveTailService) {}
154
+
155
+ async ingestLogs(envId: string, logs: LogDto[], ctx: LiveTailEnvContext) {
156
+ await this.db.appLog.createMany({ data: logs });
157
+
158
+ // Broadcast with enriched context
159
+ this.liveTail.broadcastMany(
160
+ logs.map((log) => ({
161
+ environmentId: envId,
162
+ level: log.level,
163
+ category: log.category,
164
+ action: log.action,
165
+ message: log.message,
166
+ createdAt: new Date().toISOString(),
167
+ })),
168
+ ctx, // <-- enriches each log with orgName, appName, envName, etc.
169
+ );
170
+ }
171
+ }
172
+ ```
173
+
174
+ ### LiveTailEnvContext
175
+
176
+ ```typescript
177
+ interface LiveTailEnvContext {
178
+ environmentId: string;
179
+ envName?: string;
180
+ appId?: string;
181
+ appName?: string;
182
+ orgId?: string;
183
+ orgName?: string;
184
+ }
185
+ ```
186
+
187
+ ---
188
+
189
+ ## WebSocket Protocol
190
+
191
+ ### Endpoint
192
+
193
+ ```
194
+ ws://<host>:<port>/live-tail
195
+ ```
196
+
197
+ ### Client events (send to server)
198
+
199
+ | Event | Payload | Description |
200
+ |----------------|------------------|----------------------------------------------------|
201
+ | `subscribe` | `LiveTailFilter` | Start receiving logs matching the given filter |
202
+ | `updateFilter` | `LiveTailFilter` | Update filters without reconnecting |
203
+ | `pause` | — | Pause the stream (stay connected, no logs sent) |
204
+ | `resume` | — | Resume receiving logs |
205
+
206
+ ### Server events (receive from server)
207
+
208
+ | Event | Payload | Description |
209
+ |-----------------|------------------------------------------------|----------------------------------|
210
+ | `connected` | `{ clientId, message, connectedClients }` | Connection confirmed |
211
+ | `subscribed` | `{ filter }` | Subscription confirmed |
212
+ | `filterUpdated` | `{ filter }` | Filter update confirmed |
213
+ | `paused` | — | Stream paused |
214
+ | `resumed` | — | Stream resumed |
215
+ | `log` | `LiveTailLogEvent` | A log entry matching your filter |
216
+ | `error` | `{ message }` | Connection error (e.g. max clients reached) |
217
+
218
+ ### LiveTailFilter
219
+
220
+ All fields are optional. Omit a field to accept all values for that dimension.
221
+
222
+ ```typescript
223
+ interface LiveTailFilter {
224
+ environmentId?: string; // Filter by specific environment
225
+ appId?: string; // Filter by application
226
+ orgId?: string; // Filter by organization
227
+ level?: LogLevel | LogLevel[]; // 'debug' | 'info' | 'warn' | 'error' | 'fatal'
228
+ category?: string; // Exact match on category
229
+ action?: string; // Contains match (case-insensitive)
230
+ userId?: string; // Exact match on userId
231
+ search?: string; // Full-text search in message, action, category
232
+ tags?: string[]; // Log must have at least one matching tag
233
+ }
234
+ ```
235
+
236
+ ### LiveTailLogEvent
237
+
238
+ ```typescript
239
+ interface LiveTailLogEvent {
240
+ id?: string;
241
+ environmentId: string;
242
+ level: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
243
+ category: string;
244
+ action: string;
245
+ message?: string;
246
+ metadata?: any;
247
+ userId?: string;
248
+ duration?: number; // milliseconds
249
+ tags?: string[];
250
+ createdAt: string; // ISO 8601
251
+
252
+ // Enriched fields (from environment context)
253
+ orgId?: string;
254
+ orgName?: string;
255
+ appId?: string;
256
+ appName?: string;
257
+ envName?: string;
258
+ }
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Integration Examples
264
+
265
+ ### NestJS (Monitoring API)
266
+
267
+ ```typescript
268
+ // app.module.ts
269
+ import { LiveTailModule } from '@devms/livetail';
270
+
271
+ @Module({
272
+ imports: [
273
+ LiveTailModule.register({
274
+ cors: ['https://dashboard.example.com'],
275
+ maxClients: 200,
276
+ }),
277
+ ],
278
+ })
279
+ export class AppModule {}
280
+
281
+ // app-log.controller.ts
282
+ @Post('ingest')
283
+ @UseGuards(ClientCredentialsGuard)
284
+ async ingest(@Body() dto: IngestDto, @EnvContext() ctx: EnvironmentContext) {
285
+ return this.appLogService.ingestLogs(ctx.environment.id, dto.logs, {
286
+ environmentId: ctx.environment.id,
287
+ envName: ctx.environment.name,
288
+ appId: ctx.application.id,
289
+ appName: ctx.application.name,
290
+ orgId: ctx.organization.id,
291
+ orgName: ctx.organization.name,
292
+ });
293
+ }
294
+
295
+ // app-log.service.ts
296
+ import { LiveTailService, LiveTailEnvContext } from '@devms/livetail';
297
+
298
+ @Injectable()
299
+ export class AppLogService {
300
+ constructor(
301
+ private readonly prisma: PrismaService,
302
+ @Optional() private readonly liveTail?: LiveTailService,
303
+ ) {}
304
+
305
+ async ingestLogs(envId: string, logs: AppLogDto[], ctx?: LiveTailEnvContext) {
306
+ const result = await this.prisma.appLog.createMany({ data: logs });
307
+
308
+ this.liveTail?.broadcastMany(
309
+ logs.map((log) => ({
310
+ environmentId: envId,
311
+ level: log.level as any,
312
+ category: log.category,
313
+ action: log.action,
314
+ message: log.message,
315
+ metadata: log.metadata,
316
+ userId: log.userId,
317
+ duration: log.duration,
318
+ tags: log.tags ?? [],
319
+ createdAt: new Date().toISOString(),
320
+ })),
321
+ ctx,
322
+ );
323
+
324
+ return { ingested: result.count };
325
+ }
326
+ }
327
+ ```
328
+
329
+ ### React / Next.js (Browser Client)
330
+
331
+ ```typescript
332
+ import { useEffect, useRef, useState } from 'react';
333
+ import { io, Socket } from 'socket.io-client';
334
+
335
+ function useLiveTail(apiUrl: string) {
336
+ const [logs, setLogs] = useState([]);
337
+ const [status, setStatus] = useState('disconnected');
338
+ const socketRef = useRef<Socket | null>(null);
339
+
340
+ const connect = (filter = {}) => {
341
+ const socket = io(`${apiUrl}/live-tail`, {
342
+ transports: ['websocket', 'polling'],
343
+ });
344
+
345
+ socket.on('connect', () => {
346
+ setStatus('connected');
347
+ socket.emit('subscribe', filter);
348
+ });
349
+
350
+ socket.on('disconnect', () => setStatus('disconnected'));
351
+
352
+ socket.on('log', (log) => {
353
+ setLogs((prev) => [log, ...prev].slice(0, 500));
354
+ });
355
+
356
+ socketRef.current = socket;
357
+ };
358
+
359
+ const disconnect = () => {
360
+ socketRef.current?.disconnect();
361
+ setStatus('disconnected');
362
+ };
363
+
364
+ useEffect(() => () => { socketRef.current?.disconnect(); }, []);
365
+
366
+ return { logs, status, connect, disconnect };
367
+ }
368
+ ```
369
+
370
+ ### Python (WebSocket Client)
371
+
372
+ ```python
373
+ import socketio
374
+
375
+ sio = socketio.Client()
376
+
377
+ @sio.on('connected', namespace='/live-tail')
378
+ def on_connect(data):
379
+ print(f"Connected: {data['clientId']}")
380
+ sio.emit('subscribe', {
381
+ 'environmentId': 'env-abc-123',
382
+ 'level': ['error', 'fatal'],
383
+ }, namespace='/live-tail')
384
+
385
+ @sio.on('log', namespace='/live-tail')
386
+ def on_log(data):
387
+ print(f"[{data['level']}] {data['category']}/{data['action']}: {data.get('message', '')}")
388
+
389
+ sio.connect('http://localhost:5002', namespaces=['/live-tail'])
390
+ sio.wait()
391
+ ```
392
+
393
+ ### cURL (WebSocket test with wscat)
394
+
395
+ ```bash
396
+ # Install wscat
397
+ npm install -g wscat
398
+
399
+ # Connect (note: Socket.IO uses its own protocol, wscat is for raw WS)
400
+ # For Socket.IO, use a proper client. For testing, use the browser console:
401
+ # const socket = io('http://localhost:5002/live-tail');
402
+ # socket.on('log', console.log);
403
+ # socket.emit('subscribe', { level: ['error'] });
404
+ ```
405
+
406
+ ---
407
+
408
+ ## Console Capture (raw stdout/stderr streaming)
409
+
410
+ Stream the **raw terminal output** of your application — everything the
411
+ process writes to stdout/stderr (NestJS `Logger`, `console.log`, crash stack
412
+ traces, third-party output, ANSI colors included) — like `docker logs -f`,
413
+ but over the same `/live-tail` socket. Nothing is persisted; lines are held
414
+ in an in-memory ring buffer and replayed to clients on subscribe.
415
+
416
+ ### Enable it
417
+
418
+ ```typescript
419
+ // app.module.ts
420
+ LiveTailModule.register({
421
+ captureConsole: true, // defaults: 2000-line buffer, 150ms batching
422
+ })
423
+ ```
424
+
425
+ Or with options:
426
+
427
+ ```typescript
428
+ LiveTailModule.register({
429
+ captureConsole: {
430
+ bufferSize: 2000, // lines kept in memory & replayed on subscribe
431
+ maxLineLength: 8192, // longer lines are truncated
432
+ batchInterval: 150, // ms between batched pushes to clients
433
+ token: process.env.LIVETAIL_CONSOLE_TOKEN, // require a shared secret
434
+ stripAnsi: false, // keep colors (dashboard renders them)
435
+ },
436
+ })
437
+ ```
438
+
439
+ That's it — no logger changes needed. The module tees `process.stdout` /
440
+ `process.stderr`; your terminal/PM2/Docker output is untouched.
441
+
442
+ ### Client protocol
443
+
444
+ ```typescript
445
+ const socket = io('http://localhost:5002/live-tail');
446
+
447
+ // Subscribe (replays the buffer, then streams live)
448
+ socket.emit('subscribe-console', { token: '...', tail: 1000 });
449
+
450
+ socket.on('console-subscribed', ({ pid, bufferSize, historyCount }) => {});
451
+ socket.on('console-history', ({ lines, done }) => {}); // chunked replay
452
+ socket.on('console-lines', ({ lines, dropped }) => {}); // live batches
453
+ socket.on('console-error', ({ code, message }) => {}); // disabled / bad token
454
+
455
+ socket.emit('unsubscribe-console');
456
+ ```
457
+
458
+ Each line: `{ seq: number, stream: 'stdout' | 'stderr', line: string, ts: string }`.
459
+
460
+ ### Performance & safety
461
+
462
+ - **Zero subscribers → near-zero cost.** Lines only go to the ring buffer;
463
+ nothing is broadcast.
464
+ - **Batched delivery.** Live lines are flushed every `batchInterval` ms as a
465
+ single frame, and the pending queue is hard-capped (oldest dropped, with a
466
+ `dropped` count reported) so a slow consumer can never grow memory.
467
+ - **Crash-safe tee.** The original write always executes first; any error in
468
+ capture is swallowed and can never break the app's own output.
469
+ - **Security.** Raw console output can contain secrets (connection strings,
470
+ error dumps). Set `token` in production and restrict `cors`.
471
+
472
+ ---
473
+
474
+ ## Environment Variables
475
+
476
+ | Variable | Description | Default |
477
+ |--------------------------|--------------------------------------------|---------|
478
+ | `LIVETAIL_CORS_ORIGIN` | Allowed CORS origins (comma-separated) | `*` |
479
+ | `LIVETAIL_MAX_CLIENTS` | Max simultaneous WebSocket connections | `0` |
480
+ | `LIVETAIL_DISABLED` | Set to `true` to disable the gateway | `false` |
481
+
482
+ ---
483
+
484
+ ## Architecture
485
+
486
+ ```
487
+ ┌──────────────┐ HTTP POST ┌──────────────┐ save ┌──────────────┐
488
+ │ Client App │ ──────────────────▶│ NestJS API │ ────────────▶│ PostgreSQL │
489
+ │ (any lang) │ /app-logs/ingest │ │ │ │
490
+ └──────────────┘ │ AppLogSvc │ └──────────────┘
491
+ │ │ │
492
+ │ ▼ │
493
+ │ LiveTailSvc │
494
+ │ broadcast() │
495
+ │ │ │
496
+ │ ▼ │
497
+ │ LiveTailGW │ WebSocket
498
+ │ (Socket.IO) │ ◀──────────── Browser / CLI
499
+ └──────────────┘ /live-tail
500
+ ```
501
+
502
+ 1. **Client apps** send logs via HTTP to the API (using `@devms/applog-client` or raw HTTP)
503
+ 2. **API** saves logs to the database, then calls `liveTailService.broadcast()`
504
+ 3. **LiveTailGateway** pushes matching logs to connected WebSocket clients in real-time
505
+ 4. **Browser/CLI** connects to `/live-tail` namespace, subscribes with filters, receives logs instantly
506
+
507
+ ---
508
+
509
+ ## License
510
+
511
+ MIT
@@ -0,0 +1,46 @@
1
+ import { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2
+ import { Observable } from 'rxjs';
3
+ import { ConsoleCaptureConfig, ConsoleLineEvent, LiveTailConfig } from './interfaces/livetail.interface';
4
+ /**
5
+ * Tees process.stdout / process.stderr into an in-memory ring buffer
6
+ * and an observable stream, without altering what reaches the real
7
+ * terminal. The original write always runs first and its result is
8
+ * returned unchanged — a failure in capture can never break the app's
9
+ * own output.
10
+ *
11
+ * Overhead with no subscribers is a string split + ring buffer push
12
+ * per write. Broadcasting cost only exists while clients are tailing.
13
+ */
14
+ export declare class ConsoleCaptureService implements OnModuleInit, OnModuleDestroy {
15
+ private readonly logger;
16
+ private readonly cfg;
17
+ private readonly lineSubject;
18
+ private readonly ring;
19
+ private ringIndex;
20
+ private ringCount;
21
+ private seq;
22
+ private started;
23
+ private capturing;
24
+ private originalStdout?;
25
+ private originalStderr?;
26
+ private readonly carry;
27
+ constructor(config?: LiveTailConfig);
28
+ onModuleInit(): void;
29
+ onModuleDestroy(): void;
30
+ /** Live stream of captured lines. */
31
+ get lines$(): Observable<ConsoleLineEvent>;
32
+ get enabled(): boolean;
33
+ get options(): Readonly<Required<ConsoleCaptureConfig>>;
34
+ /** Total lines captured since process start. */
35
+ get totalCaptured(): number;
36
+ /**
37
+ * Recent lines from the ring buffer, oldest first.
38
+ * @param limit Return at most this many of the newest lines.
39
+ */
40
+ getHistory(limit?: number): ConsoleLineEvent[];
41
+ clear(): void;
42
+ start(): void;
43
+ stop(): void;
44
+ private tee;
45
+ private ingest;
46
+ }