@everystack/cli 0.2.35 → 0.2.36
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/package.json +1 -1
- package/src/cli/aws.ts +70 -6
- package/src/cli/commands/logs.ts +62 -83
- package/src/cli/discover.ts +39 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observability.ts +196 -0
package/package.json
CHANGED
package/src/cli/aws.ts
CHANGED
|
@@ -141,6 +141,48 @@ export async function deleteS3(
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a live Lambda function name, healing a stale hash suffix.
|
|
146
|
+
*
|
|
147
|
+
* SST names functions `{app}-{stage}-{Resource}{Random}-{hash}`; a redeploy can
|
|
148
|
+
* rotate the trailing `-{hash}` while the base prefix stays stable. Given a name
|
|
149
|
+
* that may be stale, returns the live function matching the base prefix (most
|
|
150
|
+
* recently modified), or the original name if no better match is found.
|
|
151
|
+
*
|
|
152
|
+
* Symmetric with resolveLogGroup — needs only lambda:ListFunctions.
|
|
153
|
+
*/
|
|
154
|
+
export async function resolveFunctionName(
|
|
155
|
+
region: string,
|
|
156
|
+
functionName: string,
|
|
157
|
+
): Promise<string> {
|
|
158
|
+
const lastDash = functionName.lastIndexOf('-');
|
|
159
|
+
if (lastDash <= 0) return functionName;
|
|
160
|
+
// Include the trailing dash so the prefix can't match a longer resource name.
|
|
161
|
+
const basePrefix = functionName.slice(0, lastDash + 1).toLowerCase();
|
|
162
|
+
|
|
163
|
+
const { LambdaClient, ListFunctionsCommand } = await import('@aws-sdk/client-lambda');
|
|
164
|
+
const client = new LambdaClient({ region });
|
|
165
|
+
|
|
166
|
+
const candidates: Array<{ name: string; modified: number }> = [];
|
|
167
|
+
let marker: string | undefined;
|
|
168
|
+
do {
|
|
169
|
+
const res = await client.send(new ListFunctionsCommand({ Marker: marker, MaxItems: 50 }));
|
|
170
|
+
for (const fn of res.Functions || []) {
|
|
171
|
+
if (fn.FunctionName && fn.FunctionName.toLowerCase().startsWith(basePrefix)) {
|
|
172
|
+
candidates.push({
|
|
173
|
+
name: fn.FunctionName,
|
|
174
|
+
modified: fn.LastModified ? Date.parse(fn.LastModified) : 0,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
marker = res.NextMarker;
|
|
179
|
+
} while (marker);
|
|
180
|
+
|
|
181
|
+
if (candidates.length === 0) return functionName;
|
|
182
|
+
candidates.sort((a, b) => b.modified - a.modified);
|
|
183
|
+
return candidates[0].name;
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
export async function invokeAction(
|
|
145
187
|
region: string,
|
|
146
188
|
functionName: string,
|
|
@@ -149,13 +191,35 @@ export async function invokeAction(
|
|
|
149
191
|
): Promise<unknown> {
|
|
150
192
|
const client = await getLambda(region);
|
|
151
193
|
const { InvokeCommand } = await import('@aws-sdk/client-lambda');
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
_payload: payload,
|
|
157
|
-
})),
|
|
194
|
+
|
|
195
|
+
const encoded = new TextEncoder().encode(JSON.stringify({
|
|
196
|
+
_action: action,
|
|
197
|
+
_payload: payload,
|
|
158
198
|
}));
|
|
199
|
+
const invoke = (fnName: string) =>
|
|
200
|
+
client.send(new InvokeCommand({ FunctionName: fnName, Payload: encoded }));
|
|
201
|
+
|
|
202
|
+
let response;
|
|
203
|
+
try {
|
|
204
|
+
response = await invoke(functionName);
|
|
205
|
+
} catch (err: unknown) {
|
|
206
|
+
// Stale function hash (e.g. the discovery cache outlived a redeploy that
|
|
207
|
+
// rotated the Lambda name). Re-resolve the live function and retry once,
|
|
208
|
+
// then bust the stale cache so the next command discovers fresh.
|
|
209
|
+
if (err && typeof err === 'object' && (err as { name?: string }).name === 'ResourceNotFoundException') {
|
|
210
|
+
const live = await resolveFunctionName(region, functionName);
|
|
211
|
+
if (live === functionName) throw err;
|
|
212
|
+
try {
|
|
213
|
+
const { invalidateCacheByFunctionName } = await import('./discover.js');
|
|
214
|
+
await invalidateCacheByFunctionName(functionName);
|
|
215
|
+
} catch {
|
|
216
|
+
// Cache invalidation is best-effort
|
|
217
|
+
}
|
|
218
|
+
response = await invoke(live);
|
|
219
|
+
} else {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
159
223
|
|
|
160
224
|
if (response.FunctionError) {
|
|
161
225
|
const errorBody = response.Payload
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logs commands — query
|
|
2
|
+
* Logs commands — query the observability log archive and tail CloudWatch logs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* - Lambda container logs from CloudWatch
|
|
4
|
+
* All three commands read their store DIRECTLY — no API Lambda invoke:
|
|
5
|
+
* - logs:errors / logs:query → S3 NDJSON event archive (s3:ListBucket + s3:GetObject)
|
|
6
|
+
* - logs:tail → Lambda container logs from CloudWatch
|
|
7
|
+
*
|
|
8
|
+
* Reading the store directly (rather than invoking the function being diagnosed)
|
|
9
|
+
* means a read-only observability role works uniformly, and the commands are
|
|
10
|
+
* immune to the Lambda hash rotating on redeploy.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import { resolveConfig } from '../config.js';
|
|
10
|
-
import {
|
|
14
|
+
import { resolveLogGroup } from '../aws.js';
|
|
15
|
+
import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
|
|
11
16
|
import { step, success, fail, info } from '../output.js';
|
|
12
17
|
|
|
18
|
+
/** Default S3 key prefix for the observability archive (loggingPlugin default). */
|
|
19
|
+
const DEFAULT_PREFIX = '_observability';
|
|
20
|
+
|
|
21
|
+
/** Parse a `5m` / `1h` / `2d` duration into milliseconds; 0 if unparseable. */
|
|
22
|
+
function parseDurationMs(duration: string): number {
|
|
23
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
24
|
+
if (!match) return 0;
|
|
25
|
+
const value = parseInt(match[1], 10);
|
|
26
|
+
switch (match[2]) {
|
|
27
|
+
case 's': return value * 1000;
|
|
28
|
+
case 'm': return value * 60 * 1000;
|
|
29
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
30
|
+
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
31
|
+
default: return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
|
|
14
36
|
step('Resolving deployed config...');
|
|
15
37
|
let config;
|
|
@@ -20,50 +42,40 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
|
|
|
20
42
|
process.exit(1);
|
|
21
43
|
}
|
|
22
44
|
|
|
23
|
-
|
|
45
|
+
const prefix = flags.prefix || DEFAULT_PREFIX;
|
|
46
|
+
info(`Region: ${config.region}, Bucket: ${config.updatesBucket}/${prefix}`);
|
|
24
47
|
|
|
25
48
|
const limit = flags.limit ? parseInt(flags.limit, 10) : 20;
|
|
26
49
|
const source = flags.source;
|
|
27
|
-
const level = flags.level || 'error'; // error or fatal
|
|
50
|
+
const level = flags.level || 'error'; // error (includes fatal) or fatal
|
|
51
|
+
|
|
52
|
+
const query: ObservabilityQuery = { level, source, limit };
|
|
53
|
+
if (flags.since) {
|
|
54
|
+
const sinceMs = parseDurationMs(flags.since);
|
|
55
|
+
if (sinceMs > 0) query.startTime = new Date(Date.now() - sinceMs);
|
|
56
|
+
}
|
|
28
57
|
|
|
29
58
|
step(`Fetching last ${limit} ${level} logs...`);
|
|
30
59
|
|
|
31
60
|
try {
|
|
32
|
-
const
|
|
33
|
-
config.region,
|
|
34
|
-
config.apiFunctionName,
|
|
35
|
-
'logs:errors',
|
|
36
|
-
{ limit, source, level },
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
if (result?.error) {
|
|
40
|
-
fail(`Failed to fetch errors: ${result.error}`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const errors = result.errors || [];
|
|
61
|
+
const errors = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
|
|
45
62
|
|
|
46
63
|
if (errors.length === 0) {
|
|
47
64
|
success('No errors found!');
|
|
48
65
|
return;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
|
|
52
|
-
success(`Found ${errors.length} ${level} log(s)${sourceLabel}:\n`);
|
|
68
|
+
success(`Found ${errors.length} ${level} log(s) (s3):\n`);
|
|
53
69
|
|
|
54
|
-
// Format and display errors
|
|
55
70
|
for (const error of errors) {
|
|
56
|
-
|
|
71
|
+
const ts = new Date(error.timestamp).toISOString();
|
|
72
|
+
console.log(`[${ts}] ${(error.level || 'error').toUpperCase()}`);
|
|
57
73
|
console.log(`Source: ${error.source || 'unknown'}`);
|
|
58
74
|
console.log(`Message: ${error.message}`);
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (error.userId) {
|
|
65
|
-
console.log(`UserID: ${error.userId}`);
|
|
66
|
-
}
|
|
76
|
+
const userId = error.userId ?? error.data?.userId;
|
|
77
|
+
if (error.traceId) console.log(`TraceID: ${error.traceId}`);
|
|
78
|
+
if (userId) console.log(`UserID: ${userId}`);
|
|
67
79
|
|
|
68
80
|
if (error.error) {
|
|
69
81
|
console.log(`Error: ${error.error.name}: ${error.error.message}`);
|
|
@@ -80,7 +92,7 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
|
|
|
80
92
|
}
|
|
81
93
|
} catch (err: any) {
|
|
82
94
|
fail(`Failed to fetch errors: ${err.message}`);
|
|
83
|
-
info('Ensure your IAM user/role has
|
|
95
|
+
info('Ensure your IAM user/role has s3:ListBucket + s3:GetObject on the updates bucket.');
|
|
84
96
|
process.exit(1);
|
|
85
97
|
}
|
|
86
98
|
}
|
|
@@ -156,8 +168,8 @@ export async function logsTailCommand(flags: Record<string, string>): Promise<vo
|
|
|
156
168
|
// Resolve actual log group from Lambda config (handles SST name changes)
|
|
157
169
|
const logGroupName = await resolveLogGroup(config.region, functionName);
|
|
158
170
|
|
|
159
|
-
// Parse since duration to milliseconds
|
|
160
|
-
const sinceMs =
|
|
171
|
+
// Parse since duration to milliseconds (default 5m if unparseable)
|
|
172
|
+
const sinceMs = parseDurationMs(since) || 5 * 60 * 1000;
|
|
161
173
|
const startTime = Date.now() - sinceMs;
|
|
162
174
|
|
|
163
175
|
success(`Streaming logs from ${logGroupName}...\n`);
|
|
@@ -227,10 +239,11 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
|
|
|
227
239
|
process.exit(1);
|
|
228
240
|
}
|
|
229
241
|
|
|
230
|
-
|
|
242
|
+
const prefix = flags.prefix || DEFAULT_PREFIX;
|
|
243
|
+
info(`Region: ${config.region}, Bucket: ${config.updatesBucket}/${prefix}`);
|
|
231
244
|
|
|
232
245
|
// Build query parameters
|
|
233
|
-
const query:
|
|
246
|
+
const query: ObservabilityQuery = {
|
|
234
247
|
limit: flags.limit ? parseInt(flags.limit, 10) : 50,
|
|
235
248
|
};
|
|
236
249
|
|
|
@@ -243,32 +256,22 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
|
|
|
243
256
|
if (flags.platform) query.platform = flags.platform;
|
|
244
257
|
if (flags.environment) query.environment = flags.environment;
|
|
245
258
|
if (flags.search) query.search = flags.search;
|
|
246
|
-
if (flags.since)
|
|
259
|
+
if (flags.since) {
|
|
260
|
+
const sinceMs = parseDurationMs(flags.since);
|
|
261
|
+
if (sinceMs > 0) query.startTime = new Date(Date.now() - sinceMs);
|
|
262
|
+
}
|
|
247
263
|
|
|
248
|
-
step(`Querying logs with filters: ${JSON.stringify(
|
|
264
|
+
step(`Querying logs with filters: ${JSON.stringify(flags)}...`);
|
|
249
265
|
|
|
250
266
|
try {
|
|
251
|
-
const
|
|
252
|
-
config.region,
|
|
253
|
-
config.apiFunctionName,
|
|
254
|
-
'logs:query',
|
|
255
|
-
query,
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
if (result?.error) {
|
|
259
|
-
fail(`Failed to query logs: ${result.error}`);
|
|
260
|
-
process.exit(1);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const logs = result.logs || [];
|
|
267
|
+
const logs = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
|
|
264
268
|
|
|
265
269
|
if (logs.length === 0) {
|
|
266
270
|
success('No logs found matching the criteria.');
|
|
267
271
|
return;
|
|
268
272
|
}
|
|
269
273
|
|
|
270
|
-
|
|
271
|
-
success(`Found ${logs.length} log(s)${sourceLabel}:\n`);
|
|
274
|
+
success(`Found ${logs.length} log(s) (s3):\n`);
|
|
272
275
|
|
|
273
276
|
// Format and display logs
|
|
274
277
|
for (const log of logs) {
|
|
@@ -278,17 +281,11 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
|
|
|
278
281
|
console.log(`[${timestamp}] ${level} ${log.source || 'unknown'}`);
|
|
279
282
|
console.log(` ${log.message || '(no message)'}`);
|
|
280
283
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
console.log(` UserID: ${log.userId}`);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (log.deviceId) {
|
|
290
|
-
console.log(` DeviceID: ${log.deviceId}`);
|
|
291
|
-
}
|
|
284
|
+
const userId = log.userId ?? log.data?.userId;
|
|
285
|
+
const deviceId = log.deviceId ?? log.data?.deviceId;
|
|
286
|
+
if (log.traceId) console.log(` TraceID: ${log.traceId}`);
|
|
287
|
+
if (userId) console.log(` UserID: ${userId}`);
|
|
288
|
+
if (deviceId) console.log(` DeviceID: ${deviceId}`);
|
|
292
289
|
|
|
293
290
|
if (log.error) {
|
|
294
291
|
console.log(` Error: ${log.error.name}: ${log.error.message}`);
|
|
@@ -302,25 +299,7 @@ export async function logsQueryCommand(flags: Record<string, string>): Promise<v
|
|
|
302
299
|
}
|
|
303
300
|
} catch (err: any) {
|
|
304
301
|
fail(`Failed to query logs: ${err.message}`);
|
|
305
|
-
info('Ensure your IAM user/role has
|
|
302
|
+
info('Ensure your IAM user/role has s3:ListBucket + s3:GetObject on the updates bucket.');
|
|
306
303
|
process.exit(1);
|
|
307
304
|
}
|
|
308
305
|
}
|
|
309
|
-
|
|
310
|
-
function parseDuration(duration: string): number {
|
|
311
|
-
const match = duration.match(/^(\d+)([smhd])$/);
|
|
312
|
-
if (!match) {
|
|
313
|
-
return 5 * 60 * 1000; // Default: 5 minutes
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const value = parseInt(match[1], 10);
|
|
317
|
-
const unit = match[2];
|
|
318
|
-
|
|
319
|
-
switch (unit) {
|
|
320
|
-
case 's': return value * 1000;
|
|
321
|
-
case 'm': return value * 60 * 1000;
|
|
322
|
-
case 'h': return value * 60 * 60 * 1000;
|
|
323
|
-
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
324
|
-
default: return 5 * 60 * 1000;
|
|
325
|
-
}
|
|
326
|
-
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -384,6 +384,45 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Delete any per-stage config cache that pins `staleName` as a function name.
|
|
389
|
+
*
|
|
390
|
+
* Called after an invoke hits ResourceNotFoundException on a cached name — the
|
|
391
|
+
* cache outlived a redeploy that rotated the Lambda hash. Removing the file
|
|
392
|
+
* forces the next stage command to re-discover live. Best-effort; only touches
|
|
393
|
+
* `outputs.{stage}.json` caches, never the deploy-written `outputs.json`.
|
|
394
|
+
*/
|
|
395
|
+
export async function invalidateCacheByFunctionName(staleName: string): Promise<void> {
|
|
396
|
+
const dir = path.resolve('.sst');
|
|
397
|
+
let entries: string[];
|
|
398
|
+
try {
|
|
399
|
+
entries = await fs.readdir(dir);
|
|
400
|
+
} catch {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
if (!entry.startsWith('outputs.') || !entry.endsWith('.json') || entry === 'outputs.json') {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const filePath = path.join(dir, entry);
|
|
409
|
+
try {
|
|
410
|
+
const cached: CachedOutputs = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
411
|
+
const names = [
|
|
412
|
+
cached.apiFunctionName,
|
|
413
|
+
cached.opsFunctionName,
|
|
414
|
+
cached.imageFunctionName,
|
|
415
|
+
cached.workerFunctionName,
|
|
416
|
+
];
|
|
417
|
+
if (names.includes(staleName)) {
|
|
418
|
+
await fs.unlink(filePath);
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// Unreadable/non-matching cache — skip
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
387
426
|
export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
|
|
388
427
|
const data: CachedOutputs = {
|
|
389
428
|
_cachedAt: Date.now(),
|
package/src/cli/index.ts
CHANGED
|
@@ -188,9 +188,9 @@ Usage:
|
|
|
188
188
|
everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
|
|
189
189
|
everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
|
|
190
190
|
everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
|
|
191
|
-
everystack logs:errors [--stage <name>] [--limit 20] [--level error|fatal] [--source <name>]
|
|
191
|
+
everystack logs:errors [--stage <name>] [--limit 20] [--level error|fatal] [--source <name>] [--since 24h] [--prefix _observability]
|
|
192
192
|
everystack logs:tail [--stage <name>] [--origin api|media|worker] [--filter <pattern>] [--since 5m]
|
|
193
|
-
everystack logs:query [--stage <name>] [--level <level>] [--source <name>] [--traceId <id>] [--search <text>]
|
|
193
|
+
everystack logs:query [--stage <name>] [--level <level>] [--source <name>] [--traceId <id>] [--search <text>] [--since 1h] [--prefix _observability]
|
|
194
194
|
everystack branches list
|
|
195
195
|
everystack branches create --name <name>
|
|
196
196
|
everystack branches delete --name <name>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct reader for the S3 observability log archive.
|
|
3
|
+
*
|
|
4
|
+
* Reads the NDJSON event archive written by @everystack/logging's `S3LogStorage`
|
|
5
|
+
* WITHOUT invoking the API Lambda — the read-only analog of how `logs:tail` reads
|
|
6
|
+
* CloudWatch directly. Needs only `s3:ListBucket` + `s3:GetObject` on the updates
|
|
7
|
+
* bucket, so an observability/read-only role can run `logs:errors` / `logs:query`.
|
|
8
|
+
*
|
|
9
|
+
* FORMAT CONTRACT — mirrors @everystack/logging `S3LogStorage`. Keep the two in sync:
|
|
10
|
+
* {prefix}/events/YYYY/MM/DD/HH/{ts}-{id}.ndjson (one JSON LogEvent per line)
|
|
11
|
+
* Hour partitions are UTC. Canonical writer + query semantics:
|
|
12
|
+
* packages/logging/src/service/s3-log-storage.ts
|
|
13
|
+
*
|
|
14
|
+
* We read the format rather than import the class because @everystack/logging
|
|
15
|
+
* pulls React Native (admin/ui) through its peer graph — wrong for a standalone CLI.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { listS3, getFromS3 } from './aws.js';
|
|
19
|
+
|
|
20
|
+
/** Severity ordering — mirrors LogLevel in @everystack/logging core/types. */
|
|
21
|
+
const LOG_LEVELS: Record<string, number> = {
|
|
22
|
+
debug: 0,
|
|
23
|
+
info: 1,
|
|
24
|
+
warn: 2,
|
|
25
|
+
error: 3,
|
|
26
|
+
fatal: 4,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Minimal shape of an archived event — matches @everystack/logging LogEvent. */
|
|
30
|
+
export interface LogEventRecord {
|
|
31
|
+
id: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
level: string;
|
|
34
|
+
message: string;
|
|
35
|
+
source?: string;
|
|
36
|
+
traceId?: string;
|
|
37
|
+
data?: Record<string, unknown>;
|
|
38
|
+
error?: { name: string; message: string; stack?: string };
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ObservabilityQuery {
|
|
43
|
+
level?: string;
|
|
44
|
+
source?: string;
|
|
45
|
+
traceId?: string;
|
|
46
|
+
userId?: string;
|
|
47
|
+
deviceId?: string;
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
platform?: string;
|
|
50
|
+
environment?: string;
|
|
51
|
+
search?: string;
|
|
52
|
+
startTime?: Date;
|
|
53
|
+
endTime?: Date;
|
|
54
|
+
limit?: number;
|
|
55
|
+
offset?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Default lookback when no startTime is given — matches S3LogStorage. */
|
|
59
|
+
const DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Max in-flight S3 requests. A busy hour partition can hold thousands of NDJSON
|
|
63
|
+
* files (S3LogStorage writes one per ingest flush); an unbounded `Promise.all`
|
|
64
|
+
* over all of them — run from a laptop, not in-Lambda — exhausts the SDK socket
|
|
65
|
+
* pool and trips throttling. 32 keeps the pipe full without stampeding.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_S3_CONCURRENCY = 32;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Async map with bounded concurrency. Preserves input order in the output.
|
|
71
|
+
* A small pool of workers drains a shared index — at most `limit` run at once.
|
|
72
|
+
*/
|
|
73
|
+
async function mapLimit<T, R>(
|
|
74
|
+
items: T[],
|
|
75
|
+
limit: number,
|
|
76
|
+
fn: (item: T) => Promise<R>,
|
|
77
|
+
): Promise<R[]> {
|
|
78
|
+
const results = new Array<R>(items.length);
|
|
79
|
+
let next = 0;
|
|
80
|
+
const worker = async (): Promise<void> => {
|
|
81
|
+
while (next < items.length) {
|
|
82
|
+
const i = next;
|
|
83
|
+
next += 1;
|
|
84
|
+
results[i] = await fn(items[i]);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const pool = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
88
|
+
await Promise.all(pool);
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function padTwo(n: number): string {
|
|
93
|
+
return n.toString().padStart(2, '0');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hourPath(date: Date): string {
|
|
97
|
+
return `${date.getUTCFullYear()}/${padTwo(date.getUTCMonth() + 1)}/${padTwo(
|
|
98
|
+
date.getUTCDate(),
|
|
99
|
+
)}/${padTwo(date.getUTCHours())}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** All UTC hour partitions touched by [start, end], inclusive. */
|
|
103
|
+
function getHourPrefixes(start: Date, end: Date): string[] {
|
|
104
|
+
const prefixes: string[] = [];
|
|
105
|
+
const current = new Date(start);
|
|
106
|
+
current.setUTCMinutes(0, 0, 0);
|
|
107
|
+
while (current <= end) {
|
|
108
|
+
prefixes.push(hourPath(current));
|
|
109
|
+
current.setUTCHours(current.getUTCHours() + 1);
|
|
110
|
+
}
|
|
111
|
+
return prefixes;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseNdjson(data: string): LogEventRecord[] {
|
|
115
|
+
const records: LogEventRecord[] = [];
|
|
116
|
+
for (const line of data.split('\n')) {
|
|
117
|
+
if (line.trim().length === 0) continue;
|
|
118
|
+
try {
|
|
119
|
+
records.push(JSON.parse(line) as LogEventRecord);
|
|
120
|
+
} catch {
|
|
121
|
+
// Skip a truncated/partial line (e.g. an interrupted multipart write)
|
|
122
|
+
// rather than failing the whole query over one malformed record.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return records;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read a `data`-nested or top-level field (the app nests userId/platform under data). */
|
|
129
|
+
function field(e: LogEventRecord, key: string): unknown {
|
|
130
|
+
if (e[key] !== undefined) return e[key];
|
|
131
|
+
return e.data?.[key];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matches(e: LogEventRecord, q: ObservabilityQuery): boolean {
|
|
135
|
+
if (q.level) {
|
|
136
|
+
const threshold = LOG_LEVELS[q.level] ?? 0;
|
|
137
|
+
if ((LOG_LEVELS[e.level] ?? 0) < threshold) return false;
|
|
138
|
+
}
|
|
139
|
+
if (q.source && e.source !== q.source) return false;
|
|
140
|
+
if (q.traceId && e.traceId !== q.traceId) return false;
|
|
141
|
+
if (q.userId && String(field(e, 'userId') ?? '') !== String(q.userId)) return false;
|
|
142
|
+
if (q.deviceId && String(field(e, 'deviceId') ?? '') !== String(q.deviceId)) return false;
|
|
143
|
+
if (q.sessionId && String(field(e, 'sessionId') ?? '') !== String(q.sessionId)) return false;
|
|
144
|
+
if (q.platform && String(field(e, 'platform') ?? '') !== String(q.platform)) return false;
|
|
145
|
+
if (q.environment && String(field(e, 'environment') ?? '') !== String(q.environment)) return false;
|
|
146
|
+
if (q.startTime && e.timestamp < q.startTime.getTime()) return false;
|
|
147
|
+
if (q.endTime && e.timestamp > q.endTime.getTime()) return false;
|
|
148
|
+
if (q.search) {
|
|
149
|
+
const term = q.search.toLowerCase();
|
|
150
|
+
const hay = `${e.message ?? ''} ${JSON.stringify(e.data ?? {})}`.toLowerCase();
|
|
151
|
+
if (!hay.includes(term)) return false;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Query the S3 event archive directly. Returns newest-first, filtered + paginated.
|
|
158
|
+
*/
|
|
159
|
+
export async function queryLogArchive(
|
|
160
|
+
region: string,
|
|
161
|
+
bucket: string,
|
|
162
|
+
prefix: string,
|
|
163
|
+
query: ObservabilityQuery = {},
|
|
164
|
+
): Promise<LogEventRecord[]> {
|
|
165
|
+
const endTime = query.endTime ?? new Date();
|
|
166
|
+
const startTime =
|
|
167
|
+
query.startTime ?? new Date(endTime.getTime() - DEFAULT_LOOKBACK_MS);
|
|
168
|
+
|
|
169
|
+
const hourPrefixes = getHourPrefixes(startTime, endTime);
|
|
170
|
+
|
|
171
|
+
// List every hour partition (bounded), then flatten to one key list. A wide
|
|
172
|
+
// window (`--since 7d` → 168 hours) lists concurrently instead of serially.
|
|
173
|
+
const keyLists = await mapLimit(hourPrefixes, MAX_S3_CONCURRENCY, hour =>
|
|
174
|
+
listS3(region, bucket, `${prefix}/events/${hour}`),
|
|
175
|
+
);
|
|
176
|
+
const keys = keyLists.flat().filter(k => k.endsWith('.ndjson'));
|
|
177
|
+
|
|
178
|
+
// Fetch + parse with the same bound — caps total in-flight GETs across the
|
|
179
|
+
// whole window, not just within a single hour.
|
|
180
|
+
const files = await mapLimit(keys, MAX_S3_CONCURRENCY, k =>
|
|
181
|
+
getFromS3(region, bucket, k),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const all: LogEventRecord[] = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
if (file) all.push(...parseNdjson(file.toString()));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let results = all.filter(e => matches(e, { ...query, startTime, endTime }));
|
|
190
|
+
results.sort((a, b) => b.timestamp - a.timestamp);
|
|
191
|
+
|
|
192
|
+
if (query.offset) results = results.slice(query.offset);
|
|
193
|
+
if (query.limit) results = results.slice(0, query.limit);
|
|
194
|
+
|
|
195
|
+
return results;
|
|
196
|
+
}
|