Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

292 lines
9.0 KiB
TypeScript

import {
CloudWatchLogsClient,
DescribeLogStreamsCommand,
FilterLogEventsCommand,
GetLogEventsCommand,
GetQueryResultsCommand,
type ResultField,
} from '@aws-sdk/client-cloudwatch-logs'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
interface AwsCredentials {
region: string
accessKeyId: string
secretAccessKey: string
}
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
return new CloudWatchLogsClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
interface PollOptions {
maxWaitMs?: number
pollIntervalMs?: number
}
interface PollResult {
results: Record<string, string>[]
statistics: {
bytesScanned: number
recordsMatched: number
recordsScanned: number
}
status: string
}
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
const record: Record<string, string> = {}
if (!fields) return record
for (const field of fields) {
if (field.field && field.value !== undefined) {
record[field.field] = field.value ?? ''
}
}
return record
}
export async function pollQueryResults(
client: CloudWatchLogsClient,
queryId: string,
options: PollOptions = {}
): Promise<PollResult> {
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
const startTime = Date.now()
while (Date.now() - startTime < maxWaitMs) {
const command = new GetQueryResultsCommand({ queryId })
const response = await client.send(command)
const status = response.status ?? 'Unknown'
if (status === 'Complete') {
return {
results: (response.results ?? []).map(parseResultFields),
statistics: {
bytesScanned: response.statistics?.bytesScanned ?? 0,
recordsMatched: response.statistics?.recordsMatched ?? 0,
recordsScanned: response.statistics?.recordsScanned ?? 0,
},
status,
}
}
if (status === 'Failed' || status === 'Cancelled') {
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
}
await sleep(pollIntervalMs)
}
// Timeout -- fetch one last time for partial results
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
return {
results: (finalResponse.results ?? []).map(parseResultFields),
statistics: {
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
},
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
}
}
/** AWS DescribeLogStreams caps `limit` at 50 items per page. */
const LOG_STREAMS_PAGE_SIZE = 50
/** Upper bound on pages drained to avoid unbounded loops on log groups with many streams. */
const MAX_LOG_STREAMS_PAGES = 20
const logger = createLogger('CloudWatchUtils')
interface DescribedLogStream {
logStreamName: string
lastEventTimestamp: number | undefined
firstEventTimestamp: number | undefined
creationTime: number | undefined
storedBytes: number
}
/**
* Lists log streams for a log group, following `nextToken` so the complete set
* is returned rather than just the first page. Bounded by
* `MAX_LOG_STREAMS_PAGES`; logs a warning rather than silently dropping streams
* when the cap is hit. Ordering/prefix inputs are preserved across all pages.
*
* When `limit` is provided it is treated as a total result cap: draining stops
* once enough streams have been collected. When omitted, every page is drained.
*/
export async function describeLogStreams(
client: CloudWatchLogsClient,
logGroupName: string,
options?: { prefix?: string; limit?: number }
): Promise<{ logStreams: DescribedLogStream[] }> {
const hasPrefix = Boolean(options?.prefix)
const totalLimit = options?.limit
const logStreams: DescribedLogStream[] = []
let nextToken: string | undefined
for (let page = 0; page < MAX_LOG_STREAMS_PAGES; page++) {
const pageLimit =
totalLimit !== undefined
? Math.min(LOG_STREAMS_PAGE_SIZE, totalLimit - logStreams.length)
: LOG_STREAMS_PAGE_SIZE
const command = new DescribeLogStreamsCommand({
logGroupName,
...(hasPrefix
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
: { orderBy: 'LastEventTime', descending: true }),
limit: pageLimit,
...(nextToken && { nextToken }),
})
const response = await client.send(command)
for (const ls of response.logStreams ?? []) {
logStreams.push({
logStreamName: ls.logStreamName ?? '',
lastEventTimestamp: ls.lastEventTimestamp,
firstEventTimestamp: ls.firstEventTimestamp,
creationTime: ls.creationTime,
storedBytes: ls.storedBytes ?? 0,
})
}
nextToken = response.nextToken
if (!nextToken) break
if (totalLimit !== undefined && logStreams.length >= totalLimit) break
if (page === MAX_LOG_STREAMS_PAGES - 1) {
logger.warn(
`DescribeLogStreams hit pagination cap of ${MAX_LOG_STREAMS_PAGES} pages; log stream list may be incomplete`,
{ logGroupName }
)
}
}
return {
logStreams: totalLimit !== undefined ? logStreams.slice(0, totalLimit) : logStreams,
}
}
/** AWS FilterLogEvents caps `limit` at 10,000 events per page. */
const FILTER_LOG_EVENTS_PAGE_SIZE = 10_000
/** Upper bound on pages drained to avoid unbounded loops on very active log groups. */
const MAX_FILTER_LOG_EVENTS_PAGES = 20
interface FilteredLogEventResult {
logStreamName: string | undefined
timestamp: number | undefined
message: string | undefined
ingestionTime: number | undefined
}
/**
* Searches log events across all streams (or a prefix-matched subset) in a log
* group, following `nextToken` so the complete matching set is returned rather
* than just the first page. Bounded by `MAX_FILTER_LOG_EVENTS_PAGES`.
*
* When `limit` is provided it is treated as a total result cap: draining stops
* once enough events have been collected. When omitted, every page is drained.
*/
export async function filterLogEvents(
client: CloudWatchLogsClient,
logGroupName: string,
options?: {
filterPattern?: string
logStreamNamePrefix?: string
startTime?: number
endTime?: number
startFromHead?: boolean
limit?: number
}
): Promise<{ events: FilteredLogEventResult[] }> {
const totalLimit = options?.limit
const events: FilteredLogEventResult[] = []
let nextToken: string | undefined
for (let page = 0; page < MAX_FILTER_LOG_EVENTS_PAGES; page++) {
const pageLimit =
totalLimit !== undefined
? Math.min(FILTER_LOG_EVENTS_PAGE_SIZE, totalLimit - events.length)
: FILTER_LOG_EVENTS_PAGE_SIZE
const command = new FilterLogEventsCommand({
logGroupName,
...(options?.filterPattern && { filterPattern: options.filterPattern }),
...(options?.logStreamNamePrefix && { logStreamNamePrefix: options.logStreamNamePrefix }),
...(options?.startTime !== undefined && { startTime: options.startTime }),
...(options?.endTime !== undefined && { endTime: options.endTime }),
...(options?.startFromHead !== undefined && { startFromHead: options.startFromHead }),
limit: pageLimit,
...(nextToken && { nextToken }),
})
const response = await client.send(command)
for (const e of response.events ?? []) {
events.push({
logStreamName: e.logStreamName,
timestamp: e.timestamp,
message: e.message,
ingestionTime: e.ingestionTime,
})
}
nextToken = response.nextToken
if (!nextToken) break
if (totalLimit !== undefined && events.length >= totalLimit) break
if (page === MAX_FILTER_LOG_EVENTS_PAGES - 1) {
logger.warn(
`FilterLogEvents hit pagination cap of ${MAX_FILTER_LOG_EVENTS_PAGES} pages; event list may be incomplete`,
{ logGroupName }
)
}
}
return {
events: totalLimit !== undefined ? events.slice(0, totalLimit) : events,
}
}
export async function getLogEvents(
client: CloudWatchLogsClient,
logGroupName: string,
logStreamName: string,
options?: { startTime?: number; endTime?: number; limit?: number }
): Promise<{
events: {
timestamp: number | undefined
message: string | undefined
ingestionTime: number | undefined
}[]
}> {
const command = new GetLogEventsCommand({
logGroupName,
logStreamName,
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
...(options?.limit !== undefined && { limit: options.limit }),
startFromHead: true,
})
const response = await client.send(command)
return {
events: (response.events ?? []).map((e) => ({
timestamp: e.timestamp,
message: e.message,
ingestionTime: e.ingestionTime,
})),
}
}