-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLogsService.ts
More file actions
136 lines (128 loc) · 4.37 KB
/
LogsService.ts
File metadata and controls
136 lines (128 loc) · 4.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import { Injectable } from '@nestjs/common';
import {
CloudWatchLogsClient,
DescribeLogStreamsCommand,
FilterLogEventsCommand,
GetLogEventsCommand,
} from '@aws-sdk/client-cloudwatch-logs';
import { logger } from '../logger.js';
import { ConfigService } from './ConfigService.js';
/**
* Sleep for `ms` milliseconds, but reject immediately if `signal` is aborted.
* Used by `streamLogs` so the poll loop exits promptly when the SSE client disconnects.
*/
function sleepInterruptible(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort);
});
}
/**
* Fetches recent CloudWatch Logs lines for a game's ECS task so the UI can
* render a tail. Assumes the Terraform-provisioned log group naming
* convention `/ecs/{game}-server`.
*/
@Injectable()
export class LogsService {
private client: CloudWatchLogsClient | null = null;
constructor(private readonly config: ConfigService) {}
private getClient(): CloudWatchLogsClient {
if (!this.client) {
this.client = new CloudWatchLogsClient({ region: this.config.getRegion() });
}
return this.client;
}
/**
* Async generator that polls `FilterLogEvents` every `pollInterval` ms and
* yields new log lines as they arrive. De-duplicates by `eventId` so lines
* are never emitted twice even when `startTime` windows overlap at the
* boundary. The generator exits cleanly when `signal` is aborted (i.e.
* when the SSE client disconnects).
*
* Queries the whole log group so that a stop+start of the ECS task
* (which creates a new stream) is handled automatically without reconnecting.
*/
async *streamLogs(
game: string,
signal: AbortSignal,
pollInterval = 2000,
): AsyncGenerator<string> {
const logGroup = `/ecs/${game}-server`;
let startTime = Date.now();
const seen = new Set<string>();
while (!signal.aborted) {
try {
const resp = await this.getClient().send(
new FilterLogEventsCommand({ logGroupName: logGroup, startTime, limit: 100 }),
{ abortSignal: signal },
);
for (const e of resp.events ?? []) {
const id = e.eventId ?? `${e.timestamp}-${e.message}`;
if (!seen.has(id)) {
seen.add(id);
yield e.message ?? '';
}
if ((e.timestamp ?? 0) >= startTime) {
startTime = (e.timestamp ?? startTime) + 1;
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') break;
logger.error('Log stream poll error', { err, game, logGroup });
yield `[stream error] ${String(err)}`;
}
try {
await sleepInterruptible(pollInterval, signal);
} catch {
break;
}
}
}
/**
* Return up to `limit` recent messages from the most recently written log
* stream in `/ecs/{game}-server`. Errors are folded into a single-element
* array so the caller always renders *something* — failures in the logs
* tab shouldn't take the rest of the dashboard down.
*/
async getRecentLogs(game: string, limit = 50): Promise<string[]> {
const logGroup = `/ecs/${game}-server`;
try {
const streams = await this.getClient().send(
new DescribeLogStreamsCommand({
logGroupName: logGroup,
orderBy: 'LastEventTime',
descending: true,
limit: 1,
}),
);
if (!streams.logStreams?.length) {
return [`No log streams found for ${game}.`];
}
const streamName = streams.logStreams[0]!.logStreamName!;
const events = await this.getClient().send(
new GetLogEventsCommand({
logGroupName: logGroup,
logStreamName: streamName,
limit,
startFromHead: false,
}),
);
return events.events?.map((e) => e.message ?? '') ?? [];
} catch (err) {
logger.error('Failed to fetch logs', { err, game, logGroup });
return [`Error fetching logs for ${game}: ${String(err)}`];
}
}
}