Files
floci-ui/packages/api/src/adapter-aws/AwsLogsAdapter.ts
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

331 lines
13 KiB
TypeScript

import {
type CloudWatchLogsClient,
CreateLogGroupCommand,
CreateLogStreamCommand,
DeleteLogGroupCommand,
DeleteLogStreamCommand,
DescribeLogGroupsCommand,
DescribeLogStreamsCommand,
FilterLogEventsCommand,
GetLogEventsCommand,
GetQueryResultsCommand,
StartQueryCommand,
} from '@aws-sdk/client-cloudwatch-logs'
import type {
ChildCollection,
ChildItem,
CollectionPage,
DocumentStoreAdapter,
PageQuery,
} from '../cloud-spi/childCollections'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsLogsSchema} from '../cloud-spi/logsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
LogsInsightsQueryInput,
LogsInsightsQueryResult,
LogsFilterInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
// Real StartQuery is async and queries can run up to an hour server-side. Floci
// completes instantly by default (FLOCI_SERVICES_CLOUDWATCHLOGS_QUERY_COMPLETION_DELAY_MS=0),
// but this poll loop is bounded rather than unconditional so a future non-zero delay,
// or a real backing AWS account, cannot hang the request indefinitely.
const QUERY_POLL_INTERVAL_MS = 150
const QUERY_POLL_TIMEOUT_MS = 10_000
const ACTIVE_QUERY_STATUSES = new Set(['Running', 'Scheduled'])
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function toRow(fields: Array<{field?: string; value?: string}>): Record<string, string> {
const row: Record<string, string> = {}
for (const {field, value} of fields) {
if (field) row[field] = value ?? ''
}
return row
}
/**
* `creationTime` is the field real AWS returns and the only one the SDK models.
*
* The local runtime sends `createdTime` instead, and the SDK deserializer drops
* wire fields it does not model — so that value is unrecoverable here and
* `createdAt` is null for every log group and stream against the emulator.
* Reading the emulator's spelling instead would be dead code in production and
* would only ever pass against hand-built stubs, which is how this shipped
* wrong the first time. See
* .claude/memory/aws-sdk-error-names-differ-from-wire-codes.md — same failure
* mode, different field.
*/
interface LogGroupShape {
logGroupName?: string
creationTime?: number
arn?: string
storedBytes?: number
metricFilterCount?: number
retentionInDays?: number
}
function createdAtOf(record: {creationTime?: number}): string | null {
return record.creationTime ? new Date(record.creationTime).toISOString() : null
}
export class AwsLogsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'logs' as const
readonly documents: DocumentStoreAdapter
constructor(
private readonly client: CloudWatchLogsClient,
/** Overridable so tests can shrink the wait instead of taking QUERY_POLL_TIMEOUT_MS for real. */
private readonly queryPoll: {timeoutMs?: number; intervalMs?: number} = {},
) {
this.documents = new LogGroupDocumentStore(client)
}
schema(): ServiceSchema {
return awsLogsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const prefix = query.search?.trim()
const response = await this.client.send(
new DescribeLogGroupsCommand(prefix ? {logGroupNamePrefix: prefix} : {}),
)
return (response.logGroups ?? []).map(toResource)
}
async get(id: string): Promise<CloudResource | null> {
const group = await this.describeExact(id)
return group ? toResource(group) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = String(input.values.name ?? '').trim()
if (!name) throw new ValidationError('A log group name is required.')
await this.client.send(new CreateLogGroupCommand({logGroupName: name}))
const group = await this.describeExact(name)
if (!group) throw new NotFoundError(`Log group ${name} was created but could not be read back.`)
return toResource(group)
}
async delete(id: string): Promise<void> {
await this.client.send(new DeleteLogGroupCommand({logGroupName: id}))
}
async queryLogs(logGroupName: string, input: LogsInsightsQueryInput): Promise<LogsInsightsQueryResult> {
const queryString = input.queryString.trim()
if (!queryString) throw new ValidationError('A query string is required.')
const {queryId} = await this.client.send(
new StartQueryCommand({
logGroupName,
startTime: input.startTime,
endTime: input.endTime,
queryString,
limit: input.limit,
}),
)
if (!queryId) throw new NotFoundError(`The runtime did not return a query id for ${logGroupName}.`)
const deadline = Date.now() + (this.queryPoll.timeoutMs ?? QUERY_POLL_TIMEOUT_MS)
const pollInterval = this.queryPoll.intervalMs ?? QUERY_POLL_INTERVAL_MS
let response = await this.client.send(new GetQueryResultsCommand({queryId}))
while (ACTIVE_QUERY_STATUSES.has(response.status ?? '') && Date.now() < deadline) {
await sleep(pollInterval)
response = await this.client.send(new GetQueryResultsCommand({queryId}))
}
return {
queryId,
status: response.status ?? 'Unknown',
rows: (response.results ?? []).map(toRow),
}
}
async filterLogEvents(logGroupName: string, input: LogsFilterInput, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const response = await this.client.send(new FilterLogEventsCommand({
logGroupName,
filterPattern: input.filterPattern || undefined,
logStreamNames: input.logStreamNames?.length ? input.logStreamNames : undefined,
startTime: input.startTime,
endTime: input.endTime,
limit: page.limit,
nextToken: page.cursor,
}))
return {
items: (response.events ?? []).map((event, index) => ({
id: event.eventId ?? `${event.logStreamName ?? ''}-${event.timestamp ?? 0}-${index}`,
collectionId: event.logStreamName ?? null,
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
body: {message: event.message ?? ''},
metadata: {
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
},
})),
nextCursor: response.nextToken ?? null,
}
}
/**
* DescribeLogGroups filters by prefix, so `/floci/probe` also returns
* `/floci/probe-two`. Filtering to an exact match keeps `get` honest.
*/
private async describeExact(name: string): Promise<LogGroupShape | null> {
const response = await this.client.send(new DescribeLogGroupsCommand({logGroupNamePrefix: name}))
return (response.logGroups ?? []).find((group) => group.logGroupName === name) ?? null
}
}
/** AWS rejects a DescribeLogStreams Limit above this. GetLogEvents allows 10,000. */
const MAX_DESCRIBE_LOG_STREAMS_LIMIT = 50
interface LogStreamShape {
logStreamName?: string
arn?: string
creationTime?: number
storedBytes?: number
firstEventTimestamp?: number
lastEventTimestamp?: number
}
/**
* Streams are the collection level, events the leaf. There is deliberately no
* putItem/deleteItem/queryItems: AWS has no DeleteLogEvent, and PutLogEvents is
* not something a resource browser should expose.
*/
class LogGroupDocumentStore implements DocumentStoreAdapter {
constructor(private readonly client: CloudWatchLogsClient) {}
async listCollections(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildCollection>> {
const response = await this.client.send(
new DescribeLogStreamsCommand({
logGroupName: resourceId,
orderBy: 'LastEventTime',
descending: true,
// Real AWS caps this at 50 and rejects anything higher with a
// ValidationException; the SPI defaults to 100 and permits 1000.
// The local runtime does not enforce the cap, so an unclamped
// value passes here and 400s in production.
//
// Clamping is safe where rejecting would be wrong: `limit` is a
// maximum, not a quota, and the response still carries
// `nextCursor`, so a short page is honest rather than a silent
// truncation. That is the opposite of `clampLimit`, which
// rejects — there the caller has no cursor to tell them more
// exists.
limit: Math.min(page.limit ?? MAX_DESCRIBE_LOG_STREAMS_LIMIT, MAX_DESCRIBE_LOG_STREAMS_LIMIT),
nextToken: page.cursor,
}),
)
return {
items: (response.logStreams ?? []).map((stream) => toCollection(stream, resourceId)),
nextCursor: response.nextToken ?? null,
}
}
async createCollection(resourceId: string, input: CreateResourceInput): Promise<ChildCollection> {
const name = String(input.values.name ?? '').trim()
if (!name) throw new ValidationError('A log stream name is required.')
await this.client.send(new CreateLogStreamCommand({logGroupName: resourceId, logStreamName: name}))
const response = await this.client.send(
new DescribeLogStreamsCommand({logGroupName: resourceId, logStreamNamePrefix: name}),
)
const stream = (response.logStreams ?? []).find((candidate) => candidate.logStreamName === name)
if (!stream) throw new NotFoundError(`Log stream ${name} was created but could not be read back.`)
return toCollection(stream, resourceId)
}
async deleteCollection(resourceId: string, collectionId: string): Promise<void> {
await this.client.send(new DeleteLogStreamCommand({logGroupName: resourceId, logStreamName: collectionId}))
}
async listItems(resourceId: string, collectionId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const response = await this.client.send(
new GetLogEventsCommand({
logGroupName: resourceId,
logStreamName: collectionId,
// Without this the runtime returns the newest page, so paging
// forward from a fresh cursor would skip the history.
startFromHead: true,
limit: page.limit,
nextToken: page.cursor,
}),
)
const events = response.events ?? []
const forward = response.nextForwardToken ?? null
return {
items: events.map((event, index) => ({
// Real AWS gives log events no identifier — the SDK models only
// timestamp/message/ingestionTime, and the emulator's `eventId`
// is discarded by the deserializer. Position within the page is
// the only thing available, so it is not stable across calls and
// must not be used as a durable key.
id: `${event.timestamp ?? 0}-${index}`,
collectionId,
timestamp: event.timestamp ? new Date(event.timestamp).toISOString() : null,
body: {message: event.message ?? ''},
metadata: {
ingestionTime: event.ingestionTime ? new Date(event.ingestionTime).toISOString() : null,
},
})),
// GetLogEvents echoes the supplied token back at the end of the
// stream instead of returning null, so a naive loop never
// terminates. Verified against the runtime on 2026-07-28.
nextCursor: events.length === 0 || forward === page.cursor ? null : forward,
}
}
}
function toCollection(stream: LogStreamShape, parentId: string): ChildCollection {
const name = stream.logStreamName ?? ''
return {
id: name,
name,
parentId,
createdAt: createdAtOf(stream),
metadata: {
arn: stream.arn,
storedBytes: stream.storedBytes ?? 0,
firstEventTimestamp: stream.firstEventTimestamp
? new Date(stream.firstEventTimestamp).toISOString()
: null,
lastEventTimestamp: stream.lastEventTimestamp
? new Date(stream.lastEventTimestamp).toISOString()
: null,
},
}
}
function toResource(group: LogGroupShape): CloudResource {
const name = group.logGroupName ?? ''
return {
id: name,
name,
cloud: 'aws',
service: 'logs',
type: 'log-group',
region: null,
createdAt: createdAtOf(group),
metadata: {
arn: group.arn,
storedBytes: group.storedBytes ?? 0,
metricFilterCount: group.metricFilterCount ?? 0,
retentionInDays: group.retentionInDays ?? null,
},
}
}