Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add logs option to CLI to print / tail logs from deployed agends #192

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-news-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'flatfile': minor
---

Added support for retrieving logs from deployed flatfile agents (flatfile logs)
32 changes: 30 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { program } from 'commander'
import { program, Option } from 'commander'
import dotenv from 'dotenv'
import ora from 'ora'

Expand All @@ -13,10 +13,11 @@ import { createEnvironmentAction } from './x/actions/create.environment.action'
import { deployAction } from './x/actions/deploy.action'
import { deleteAction } from './x/actions/delete.action'
import { developAction } from './x/actions/develop.action'
import { listAgentsAction } from './x/actions/list-agents.action'
import { logsAction } from './x/actions/logs.action'
import { publishAction } from './x/actions/publish.action'
import { publishPubSub } from './x/actions/publish.pubsub'
import { quickstartAction } from './x/actions/quickstart.action'
import { listAgentsAction } from './x/actions/list-agents.action'

dotenv.config()

Expand Down Expand Up @@ -62,6 +63,33 @@ program
)
.action(deployAction)

program
.command('logs')
.description('Display the logs from a specified Agent')
.option(
'-s, --slug <slug>',
'the slug of the project to display the logs for (or set env FLATFILE_AGENT_SLUG)'
)
.option(
'-t, --tail',
'continuously display logs as they are generated until the process is terminated'
)
.addOption(
new Option(
'-n, --number <number>',
'the number of logs to display'
).argParser((val) => parseInt(val))
)
.option(
'-k, --token <token>',
'the authentication token to use (or set env FLATFILE_API_KEY or FLATFILE_BEARER_TOKEN)'
)
.option(
'-h, --api-url <url>',
'(optional) the API URL to use (or set env FLATFILE_API_URL)'
)
.action(logsAction)

program
.command('develop [file]')
.alias('dev [file]')
Expand Down
209 changes: 209 additions & 0 deletions packages/cli/src/x/actions/logs.action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { Flatfile } from '@flatfile/api'
import { program } from 'commander'
import chalk from 'chalk'
import ora from 'ora'
import prompts from 'prompts'

import { apiKeyClient } from './auth.action'
import { getAuth } from '../../shared/get-auth'
import { messages } from '../../shared/messages'

/**
* isStatusLogLine
*
* Returns true if the line is a status line reported by AWS Lambda.
*
* @param {string} line - The line to check
* @returns {boolean} - True if the line is a status line
*/
function isStatusLogLine(line: string): boolean {
return (
line.startsWith('REPORT') ||
line.startsWith('START') ||
line.startsWith('END')
)
}

/**
* handleAgentSelection
*
* Prompts the user to select a deployed agent unless one is specified or there's only one agent.
*
* @param {Flatfile.Agent[] | undefined} data - the list of agents
* @param {string | undefined} slug - the slug of the agent to select
* @returns {Promise<Flatfile.Agent | undefined>} - the selected agent
*/
async function handleAgentSelection(
data: Flatfile.Agent[] | undefined,
slug: string | undefined
) {
// Directly return if there's no data or if a slug is already provided
if (!data || slug) {
return data?.find((a) => a.slug === slug)
}

if (data.length > 1) {
const { agent } = await prompts({
type: 'select',
name: 'agent',
message: 'Select an agent to display logs for:',
choices: data.map((a) => ({
title: a.slug || '<no-slug>',
value: a.slug,
})),
})

return data.find((a) => a.slug === agent)
} else {
// If there's only one agent and no slug is provided, return the first agent
return data[0]
}
}

/**
* printLogs
*
* Parses and prints the logs from an array of agent logs.
*
* @param {Flatfile.AgentLog[]} logs - the logs to print
*/
function printLogs(logs: Flatfile.AgentLog[]) {
for (const log of logs) {
if (log.success == false) {
console.log(
`${chalk.gray(log.createdAt)} ${chalk.red('ERROR')} ${log.log}`
)
continue
}

const logLines =
log.log
?.split('\n')
.filter(Boolean)
.filter((line) => !isStatusLogLine(line)) || []

for (const logLine of logLines.reverse()) {
const parts = logLine.split('\t')

if (parts.length < 4) {
continue
}

let [timestamp, _id, level, ...message] = parts

if (level === 'INFO') {
level = chalk.blue(level)
} else if (level === 'WARN') {
level = chalk.yellow(level)
} else if (level === 'ERROR') {
level = chalk.red(level)
}

console.log(`${chalk.gray(timestamp)} ${level} ${message.join('\n')}`)
}
}
}

export async function logsAction(
options?: Partial<{
slug: string
apiUrl: string
token: string
number: number
tail: boolean
}>
): Promise<void> {
let authRes
try {
authRes = await getAuth(options)
} catch (e) {
return program.error(messages.error(e))
}

const { apiKey, apiUrl, environment } = authRes
const slug = options?.slug || process.env.FLATFILE_AGENT_SLUG
const apiClient = apiKeyClient({ apiUrl, apiKey: apiKey! })

/**
* fetchLogs
*
* Fetchs and returns the logs for an agent. Optionally, fetch logs since a given ID.
*
* @param {Flatfile.AgentId} agentId - the ID of the agent to fetch logs for
* @param {Flatfile.EventId} sinceEventId - the ID of the event log to fetch logs since
* @returns {Promise<Flatfile.AgentLog[]>} - the logs
*/
const fetchLogs = async (
agentId: Flatfile.AgentId,
sinceEvent?: Flatfile.AgentLog
): Promise<Flatfile.AgentLog[]> => {
const { data: logs = [] } = await apiClient.agents.getAgentLogs(agentId, {
environmentId: environment.id!,
})

if (!sinceEvent) return logs

const filtered = logs.filter(
(log) =>
log.createdAt >= sinceEvent.createdAt &&
log.eventId !== sinceEvent.eventId
)

return filtered
}

try {
const agentSpinner = ora({
text: `Select agent to display logs for...`,
}).start()
const { data } = await apiClient.agents.list({
environmentId: environment.id!,
})
const selectedAgent = await handleAgentSelection(data, slug)

if (!selectedAgent) {
const errorMessage = slug
? `Agent with slug ${chalk.cyan(slug)} not found`
: 'No agents found'

agentSpinner.fail(errorMessage)
process.exit(1)
}

const agentName = selectedAgent.slug || selectedAgent.id
agentSpinner.succeed(`Selected agent: ${chalk.cyan(agentName)}`)

const logs = await fetchLogs(selectedAgent.id)
const maxLogs = options?.number || logs.length
const initialLogs = logs.slice(0, maxLogs).reverse()

// Print the intially requested logs
printLogs(initialLogs)

if (options?.tail) {
let lastEvent = initialLogs[initialLogs.length - 1]
let timer: ReturnType<typeof setTimeout>

// The logs endpoint does not support streaming responses, so we need to poll every few seconds.
const poll = async () => {
const newLogs = (await fetchLogs(selectedAgent.id, lastEvent)).reverse()

if (newLogs.length > 0) {
printLogs(newLogs)
lastEvent = newLogs[newLogs.length - 1]
}

timer = setTimeout(poll, 2500)
theycallmeswift marked this conversation as resolved.
Show resolved Hide resolved
}

poll()

process.on('SIGINT', () => {
if (timer) clearTimeout(timer)
process.exit()
})
}
} catch (e) {
return program.error(messages.error(e))
}
}
Comment on lines +107 to +209
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider refactoring the function to improve readability and maintainability.

The logsAction function is quite large and has multiple responsibilities. To improve readability and maintainability, consider the following suggestions:

  1. Extract the authentication logic into a separate function.
  2. Extract the agent selection logic into a separate function.
  3. Extract the log fetching and printing logic into separate functions.
  4. Extract the polling logic into a separate function.

By breaking down the function into smaller, focused functions, the code will become more modular and easier to understand and maintain.

Loading