From 634ed26a868a8b9667e649e587335ce767a96fe8 Mon Sep 17 00:00:00 2001 From: Shruti Padamata Date: Thu, 20 Aug 2026 16:51:49 -0700 Subject: [PATCH 1/3] feat: Add log:list CLI command --- genkit-tools/cli/src/cli.ts | 2 + genkit-tools/cli/src/commands/log-list.ts | 157 +++++++++++ genkit-tools/cli/src/commands/trace-list.ts | 4 +- .../cli/tests/commands/log-list_test.ts | 251 ++++++++++++++++++ .../cli/tests/commands/trace-list_test.ts | 2 +- genkit-tools/common/src/manager/manager.ts | 8 + genkit-tools/common/src/types/apis.ts | 2 + genkit-tools/common/src/types/log.ts | 2 + .../telemetry-server/src/file-log-store.ts | 11 + genkit-tools/telemetry-server/src/index.ts | 21 +- 10 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 genkit-tools/cli/src/commands/log-list.ts create mode 100644 genkit-tools/cli/tests/commands/log-list_test.ts diff --git a/genkit-tools/cli/src/cli.ts b/genkit-tools/cli/src/cli.ts index b119d59bea..b8434dd2a4 100644 --- a/genkit-tools/cli/src/cli.ts +++ b/genkit-tools/cli/src/cli.ts @@ -33,6 +33,7 @@ import { evalRun } from './commands/eval-run'; import { flowBatchRun } from './commands/flow-batch-run'; import { flowRun } from './commands/flow-run'; import { initAiTools } from './commands/init-ai-tools/index'; +import { logList } from './commands/log-list'; import { mcp } from './commands/mcp'; import { getPluginCommands, getPluginSubCommand } from './commands/plugins'; import { @@ -72,6 +73,7 @@ const commands: Command[] = [ docsList, docsRead, docsSearch, + logList, traceGet, traceList, ]; diff --git a/genkit-tools/cli/src/commands/log-list.ts b/genkit-tools/cli/src/commands/log-list.ts new file mode 100644 index 0000000000..478c8e4bde --- /dev/null +++ b/genkit-tools/cli/src/commands/log-list.ts @@ -0,0 +1,157 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LogQueryFilter } from '@genkit-ai/tools-common'; +import type { BaseRuntimeManager } from '@genkit-ai/tools-common/manager'; +import { findProjectRoot, logger } from '@genkit-ai/tools-common/utils'; +import { Command } from 'commander'; +import { runWithManager } from '../utils/manager-utils'; + +export interface LogListOptions { + limit: string; + traceId?: string; + spanId?: string; + severity?: string; + continuationToken?: string; + verbose?: boolean; +} + +/** + * Command to list logs. By default, logs are returned in reverse + * chronological order. + */ +export const logList = new Command('log:list') + .description('list logs') + .option('-l, --limit ', 'limit the number of returned logs', '15') + .option('--trace-id ', 'filter by trace ID') + .option('--span-id ', 'filter by span ID') + .option( + '--severity ', + 'filter by severity (e.g., INFO, ERROR, WARNING)' + ) + .option( + '-v, --verbose', + 'display the full JSON log instead of the preview message' + ) + .option('--continuation-token ', 'continuation token for pagination') + .action(async (options: LogListOptions) => { + const projectRoot = await findProjectRoot(); + + const runAction = async (manager: BaseRuntimeManager) => { + try { + const filter: LogQueryFilter = {}; + if (options.traceId) { + filter.traceId = options.traceId; + } + if (options.spanId) { + filter.spanId = options.spanId; + } + if (options.severity) { + filter.severityText = options.severity; + } + + const listRequest = { + limit: Number.parseInt(options.limit, 10), + continuationToken: options.continuationToken, + filter: Object.keys(filter).length > 0 ? filter : undefined, + }; + + const response = await manager.listLogs(listRequest); + + if (!response || !response.logs || response.logs.length === 0) { + logger.info('No logs found.'); + return; + } + + const logs = response.logs; + + if (options.verbose) { + logs.forEach((log) => { + console.log(JSON.stringify(log, null, 2)); + console.log('---'); + }); + } else { + console.log( + `Found ${logs.length} log${logs.length === 1 ? '' : 's'}:\n` + ); + logs.forEach((log) => { + let time = 'unknown'; + if (log.timestamp) { + time = new Date(log.timestamp).toLocaleString(); + } + + const id = log.logId || 'unknown'; + const severity = log.severityText || 'unknown'; + const message = formatBody(log.body); + const attributes = formatAttributes(log.attributes); + + console.log(`ID: ${id}`); + console.log(`Severity: ${severity}`); + console.log(`Time: ${time}`); + if (message) console.log(`Message: ${message}`); + if (attributes) console.log(`Attrs: ${attributes}`); + + console.log('---'); + }); + } + + if (response.continuationToken) { + console.log( + `\nTo get the next page, use: --continuation-token ${response.continuationToken}` + ); + } + } catch (e) { + logger.error(`Error listing logs: ${e}`); + } + }; + + await runWithManager(projectRoot, runAction); + }); + +function formatBody(value: unknown): string { + if (value === undefined || value === null) return ''; + const strValue = + typeof value === 'object' ? JSON.stringify(value) : String(value); + + // If it's a long string and doesn't match patterns, limit it + return strValue.length > 100 ? strValue.substring(0, 100) + '...' : strValue; +} + +function formatAttributes( + attributes: Record | undefined +): string { + if (!attributes || Object.keys(attributes).length === 0) return ''; + const pairs = Object.entries(attributes).map(([key, value]) => { + let strValue: string; + if (typeof value === 'object' && value !== null) { + try { + strValue = JSON.stringify(value); + } catch { + strValue = '[Object]'; + } + } else { + strValue = String(value); + } + const truncated = + strValue.length > 50 ? strValue.substring(0, 50) + '...' : strValue; + return `${key}=${truncated}`; + }); + + const fullString = pairs.join(', '); + return fullString.length > 100 + ? fullString.substring(0, 100) + '...' + : fullString; +} diff --git a/genkit-tools/cli/src/commands/trace-list.ts b/genkit-tools/cli/src/commands/trace-list.ts index 80eeeafb8a..73bd665604 100644 --- a/genkit-tools/cli/src/commands/trace-list.ts +++ b/genkit-tools/cli/src/commands/trace-list.ts @@ -92,7 +92,9 @@ export const traceList = new Command('trace:list') return; } - console.log(`Found ${response.traces.length} traces:\n`); + console.log( + `Found ${response.traces.length} trace${response.traces.length === 1 ? '' : 's'}:\n` + ); response.traces.forEach((trace) => { let duration = 'unknown'; diff --git a/genkit-tools/cli/tests/commands/log-list_test.ts b/genkit-tools/cli/tests/commands/log-list_test.ts new file mode 100644 index 0000000000..6a5af0ded3 --- /dev/null +++ b/genkit-tools/cli/tests/commands/log-list_test.ts @@ -0,0 +1,251 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { findProjectRoot, logger } from '@genkit-ai/tools-common/utils'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { logList } from '../../src/commands/log-list'; +import { runWithManager } from '../../src/utils/manager-utils'; + +jest.mock('@genkit-ai/tools-common/utils'); +jest.mock('../../src/utils/manager-utils'); + +describe('log:list command', () => { + let mockManager: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockManager = { + listLogs: jest.fn(), + }; + + (findProjectRoot as jest.Mock).mockResolvedValue('/mock/project/root'); + + (runWithManager as jest.Mock).mockImplementation( + async (projectRoot: any, action: any) => { + await action(mockManager); + } + ); + + jest.spyOn(logger, 'info').mockImplementation((() => {}) as any); + jest.spyOn(logger, 'error').mockImplementation((() => {}) as any); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should list logs with default limit', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [ + { + timestamp: 1718302195000, + severityText: 'INFO', + body: 'Hello World', + attributes: { + 'genkit:name': 'test-log', + }, + }, + ], + }); + + await logList.parseAsync(['node', 'log:list']); + + expect(findProjectRoot).toHaveBeenCalled(); + expect(runWithManager).toHaveBeenCalled(); + expect(mockManager.listLogs).toHaveBeenCalledWith({ + limit: 15, + continuationToken: undefined, + filter: undefined, + }); + expect(console.log).toHaveBeenCalledWith('Found 1 log:\n'); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('INFO')); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Hello World') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Attrs: genkit:name=test-log') + ); + }); + + it('should handle pagination with continuation token', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [ + { + timestamp: 1718302195000, + body: 'Another log', + }, + ], + continuationToken: 'next-page-token', + }); + + await logList.parseAsync([ + 'node', + 'log:list', + '--limit', + '15', + '--severity', + '', + '--trace-id', + '', + '--span-id', + '', + '--continuation-token', + 'some-token', + ]); + + expect(mockManager.listLogs).toHaveBeenCalledWith({ + limit: 15, + continuationToken: 'some-token', + filter: undefined, + }); + expect(console.log).toHaveBeenCalledWith( + '\nTo get the next page, use: --continuation-token next-page-token' + ); + }); + + it('should list logs with filters and custom limit', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [ + { + timestamp: 1718302195000, + severityText: 'ERROR', + body: 'An error occurred', + }, + ], + }); + + await logList.parseAsync([ + 'node', + 'log:list', + '--limit', + '5', + '--severity', + 'ERROR', + '--trace-id', + 'trace-1', + '--span-id', + 'span-1', + '--continuation-token', + '', + ]); + + expect(mockManager.listLogs).toHaveBeenCalledWith({ + limit: 5, + continuationToken: '', + filter: { + severityText: 'ERROR', + traceId: 'trace-1', + spanId: 'span-1', + }, + }); + }); + + it('should output formatted logs without verbose flag', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [ + { + logId: 'log-123', + timestamp: 1718302195000, + severityText: 'WARN', + body: 'A'.repeat(110), + attributes: { key: 'value' }, + }, + ], + }); + + await logList.parseAsync([ + 'node', + 'log:list', + '--limit', + '15', + '--severity', + '', + '--trace-id', + '', + '--span-id', + '', + '--continuation-token', + '', + ]); + + expect(console.log).toHaveBeenCalledWith('ID: log-123'); + expect(console.log).toHaveBeenCalledWith('Severity: WARN'); + expect(console.log).toHaveBeenCalledWith( + `Time: ${new Date(1718302195000).toLocaleString()}` + ); + expect(console.log).toHaveBeenCalledWith(`Message: ${'A'.repeat(100)}...`); + expect(console.log).toHaveBeenCalledWith('Attrs: key=value'); + }); + + it('should output full logs as json in verbose mode', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [ + { + timestamp: 1718302195000, + body: 'Verbose log', + }, + ], + }); + + await logList.parseAsync([ + 'node', + 'log:list', + '--verbose', + '--limit', + '15', + '--severity', + '', + '--trace-id', + '', + '--span-id', + '', + '--continuation-token', + '', + ]); + + expect(mockManager.listLogs).toHaveBeenCalledWith({ + limit: 15, + continuationToken: '', + filter: undefined, + }); + + // In verbose mode, it dumps the full JSON using JSON.stringify(log, null, 2) + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"body": "Verbose log"') + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"timestamp": 1718302195000') + ); + }); + + it('should log info when no logs are found', async () => { + mockManager.listLogs.mockResolvedValue({ + logs: [], + }); + + await logList.parseAsync(['node', 'log:list']); + + expect(logger.info).toHaveBeenCalledWith('No logs found.'); + }); + + it('should handle and log errors', async () => { + mockManager.listLogs.mockRejectedValue(new Error('API failure')); + + await logList.parseAsync(['node', 'log:list']); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error listing logs: Error: API failure') + ); + }); +}); diff --git a/genkit-tools/cli/tests/commands/trace-list_test.ts b/genkit-tools/cli/tests/commands/trace-list_test.ts index f0595d79ab..f7c1ed8240 100644 --- a/genkit-tools/cli/tests/commands/trace-list_test.ts +++ b/genkit-tools/cli/tests/commands/trace-list_test.ts @@ -81,7 +81,7 @@ describe('trace:list command', () => { neq: { 'genkitx:ignore-trace': ['true'] }, }, }); - expect(console.log).toHaveBeenCalledWith('Found 1 traces:\n'); + expect(console.log).toHaveBeenCalledWith('Found 1 trace:\n'); expect(console.log).toHaveBeenCalledWith('ID: trace-1'); expect(console.log).toHaveBeenCalledWith('Status: success'); expect(console.log).toHaveBeenCalledWith('Input: my input string'); diff --git a/genkit-tools/common/src/manager/manager.ts b/genkit-tools/common/src/manager/manager.ts index b007d877e4..ded6661264 100644 --- a/genkit-tools/common/src/manager/manager.ts +++ b/genkit-tools/common/src/manager/manager.ts @@ -157,6 +157,14 @@ export abstract class BaseRuntimeManager { if (query !== '') query += '&'; query += `continuationToken=${continuationToken}`; } + if (filter?.severityText) { + if (query !== '') query += '&'; + query += `severityText=${filter.severityText}`; + } + if (filter?.severityNumber) { + if (query !== '') query += '&'; + query += `severityNumber=${filter.severityNumber}`; + } const fullUrl = query !== '' ? `${url}?${query}` : url; diff --git a/genkit-tools/common/src/types/apis.ts b/genkit-tools/common/src/types/apis.ts index 02c38eb4b7..966cecbbf7 100644 --- a/genkit-tools/common/src/types/apis.ts +++ b/genkit-tools/common/src/types/apis.ts @@ -68,6 +68,8 @@ export type ListTracesResponse = z.infer; export const LogQueryFilterSchema = z.object({ traceId: z.string().optional(), spanId: z.string().optional(), + severityText: z.string().optional(), + severityNumber: z.number().optional(), }); export type LogQueryFilter = z.infer; diff --git a/genkit-tools/common/src/types/log.ts b/genkit-tools/common/src/types/log.ts index d5242bcc52..ad2007ab6c 100644 --- a/genkit-tools/common/src/types/log.ts +++ b/genkit-tools/common/src/types/log.ts @@ -50,6 +50,8 @@ export const LogQuerySchema = z.object({ continuationToken: z.string().optional(), traceId: z.string().optional(), spanId: z.string().optional(), + severityText: z.string().optional(), + severityNumber: z.number().optional(), }); export type LogQuery = z.infer; diff --git a/genkit-tools/telemetry-server/src/file-log-store.ts b/genkit-tools/telemetry-server/src/file-log-store.ts index dce409618c..c95269338d 100644 --- a/genkit-tools/telemetry-server/src/file-log-store.ts +++ b/genkit-tools/telemetry-server/src/file-log-store.ts @@ -114,6 +114,8 @@ export class LocalFileLogStore implements LogStore { startFromIndex, traceId: query?.traceId, spanId: query?.spanId, + severityText: query?.severityText, + severityNumber: query?.severityNumber, }); const logs: LogRecordData[] = []; @@ -235,6 +237,8 @@ export class LogIndex { startFromIndex: number; traceId?: string; spanId?: string; + severityText?: string; + severityNumber?: number; }): LogIndexSearchResult { const indexFiles = this.listIndexFiles().sort().reverse(); @@ -263,6 +267,13 @@ export class LogIndex { if (!d) return false; if (query.traceId && d.traceId !== query.traceId) return false; if (query.spanId && d.spanId !== query.spanId) return false; + if ( + query.severityText && + d.severityText?.toLowerCase() !== query.severityText.toLowerCase() + ) + return false; + if (query.severityNumber && d.severityNumber !== query.severityNumber) + return false; return true; }); diff --git a/genkit-tools/telemetry-server/src/index.ts b/genkit-tools/telemetry-server/src/index.ts index 4788987700..55c1d7218e 100644 --- a/genkit-tools/telemetry-server/src/index.ts +++ b/genkit-tools/telemetry-server/src/index.ts @@ -218,13 +218,18 @@ export async function startTelemetryServer(params: { api.get('/api/logs', async (request, response, next) => { try { - const { limit, continuationToken } = request.query; + const { limit, continuationToken, severityText, severityNumber } = + request.query; response.json( await params.logStore.list({ limit: limit ? Number.parseInt(limit.toString()) : 100, continuationToken: continuationToken ? continuationToken.toString() : undefined, + severityText: severityText ? severityText.toString() : undefined, + severityNumber: severityNumber + ? Number.parseInt(severityNumber.toString()) + : undefined, }) ); } catch (e) { @@ -234,7 +239,8 @@ export async function startTelemetryServer(params: { api.get('/api/traces/:traceId/logs', async (request, response, next) => { try { - const { limit, continuationToken } = request.query; + const { limit, continuationToken, severityText, severityNumber } = + request.query; const { traceId } = request.params; response.json( await params.logStore.list({ @@ -243,6 +249,10 @@ export async function startTelemetryServer(params: { ? continuationToken.toString() : undefined, traceId, + severityText: severityText ? severityText.toString() : undefined, + severityNumber: severityNumber + ? Number.parseInt(severityNumber.toString()) + : undefined, }) ); } catch (e) { @@ -254,7 +264,8 @@ export async function startTelemetryServer(params: { '/api/traces/:traceId/spans/:spanId/logs', async (request, response, next) => { try { - const { limit, continuationToken } = request.query; + const { limit, continuationToken, severityText, severityNumber } = + request.query; const { traceId, spanId } = request.params; response.json( await params.logStore.list({ @@ -264,6 +275,10 @@ export async function startTelemetryServer(params: { : undefined, traceId, spanId, + severityText: severityText ? severityText.toString() : undefined, + severityNumber: severityNumber + ? Number.parseInt(severityNumber.toString()) + : undefined, }) ); } catch (e) { From 309b60cc65c3748bc4ed8348ad0babeff2c9f9e7 Mon Sep 17 00:00:00 2001 From: Shruti Padamata Date: Fri, 21 Aug 2026 00:08:12 -0700 Subject: [PATCH 2/3] Handle GCA comments --- genkit-tools/cli/src/commands/log-list.ts | 22 +++++++++++++++++++--- genkit-tools/common/src/manager/manager.ts | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/genkit-tools/cli/src/commands/log-list.ts b/genkit-tools/cli/src/commands/log-list.ts index 478c8e4bde..da3767855c 100644 --- a/genkit-tools/cli/src/commands/log-list.ts +++ b/genkit-tools/cli/src/commands/log-list.ts @@ -63,8 +63,16 @@ export const logList = new Command('log:list') filter.severityText = options.severity; } + const limit = Number.parseInt(options.limit, 10); + if (Number.isNaN(limit) || limit <= 0) { + logger.error( + `Invalid limit: "${options.limit}". It must be a positive integer.` + ); + return; + } + const listRequest = { - limit: Number.parseInt(options.limit, 10), + limit, continuationToken: options.continuationToken, filter: Object.keys(filter).length > 0 ? filter : undefined, }; @@ -123,8 +131,16 @@ export const logList = new Command('log:list') function formatBody(value: unknown): string { if (value === undefined || value === null) return ''; - const strValue = - typeof value === 'object' ? JSON.stringify(value) : String(value); + let strValue: string; + if (typeof value === 'object') { + try { + strValue = JSON.stringify(value); + } catch { + strValue = '[Object]'; + } + } else { + strValue = String(value); + } // If it's a long string and doesn't match patterns, limit it return strValue.length > 100 ? strValue.substring(0, 100) + '...' : strValue; diff --git a/genkit-tools/common/src/manager/manager.ts b/genkit-tools/common/src/manager/manager.ts index ded6661264..cf8baaff33 100644 --- a/genkit-tools/common/src/manager/manager.ts +++ b/genkit-tools/common/src/manager/manager.ts @@ -159,7 +159,7 @@ export abstract class BaseRuntimeManager { } if (filter?.severityText) { if (query !== '') query += '&'; - query += `severityText=${filter.severityText}`; + query += `severityText=${encodeURIComponent(filter.severityText)}`; } if (filter?.severityNumber) { if (query !== '') query += '&'; From ab71c0a7fcbbd7e81e91469380cb745a00bd1024 Mon Sep 17 00:00:00 2001 From: Shruti Padamata Date: Fri, 21 Aug 2026 16:22:37 -0700 Subject: [PATCH 3/3] Replace verbose flag with jsonl format flag --- genkit-tools/cli/src/commands/log-list.ts | 39 +++++++++++++------ .../cli/tests/commands/log-list_test.ts | 17 ++++---- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/genkit-tools/cli/src/commands/log-list.ts b/genkit-tools/cli/src/commands/log-list.ts index da3767855c..382634782a 100644 --- a/genkit-tools/cli/src/commands/log-list.ts +++ b/genkit-tools/cli/src/commands/log-list.ts @@ -16,8 +16,12 @@ import type { LogQueryFilter } from '@genkit-ai/tools-common'; import type { BaseRuntimeManager } from '@genkit-ai/tools-common/manager'; -import { findProjectRoot, logger } from '@genkit-ai/tools-common/utils'; -import { Command } from 'commander'; +import { + findProjectRoot, + forceStderr, + logger, +} from '@genkit-ai/tools-common/utils'; +import { Command, Option } from 'commander'; import { runWithManager } from '../utils/manager-utils'; export interface LogListOptions { @@ -26,7 +30,7 @@ export interface LogListOptions { spanId?: string; severity?: string; continuationToken?: string; - verbose?: boolean; + format: 'text' | 'jsonl'; } /** @@ -42,12 +46,14 @@ export const logList = new Command('log:list') '--severity ', 'filter by severity (e.g., INFO, ERROR, WARNING)' ) - .option( - '-v, --verbose', - 'display the full JSON log instead of the preview message' + .addOption( + new Option('-f, --format ', 'output format') + .choices(['text', 'jsonl']) + .default('text') ) .option('--continuation-token ', 'continuation token for pagination') .action(async (options: LogListOptions) => { + if (options.format === 'jsonl') forceStderr(); const projectRoot = await findProjectRoot(); const runAction = async (manager: BaseRuntimeManager) => { @@ -86,10 +92,9 @@ export const logList = new Command('log:list') const logs = response.logs; - if (options.verbose) { + if (options.format === 'jsonl') { logs.forEach((log) => { - console.log(JSON.stringify(log, null, 2)); - console.log('---'); + console.log(JSON.stringify(log)); }); } else { console.log( @@ -107,6 +112,10 @@ export const logList = new Command('log:list') const attributes = formatAttributes(log.attributes); console.log(`ID: ${id}`); + if (!options.traceId && log.traceId) + console.log(`Trace ID: ${log.traceId}`); + if (!options.spanId && log.spanId) + console.log(`Span ID: ${log.spanId}`); console.log(`Severity: ${severity}`); console.log(`Time: ${time}`); if (message) console.log(`Message: ${message}`); @@ -117,9 +126,15 @@ export const logList = new Command('log:list') } if (response.continuationToken) { - console.log( - `\nTo get the next page, use: --continuation-token ${response.continuationToken}` - ); + if (options.format === 'jsonl') { + logger.info( + `To get the next page, use: --continuation-token ${response.continuationToken}` + ); + } else { + console.log( + `\nTo get the next page, use: --continuation-token ${response.continuationToken}` + ); + } } } catch (e) { logger.error(`Error listing logs: ${e}`); diff --git a/genkit-tools/cli/tests/commands/log-list_test.ts b/genkit-tools/cli/tests/commands/log-list_test.ts index 6a5af0ded3..525b21029e 100644 --- a/genkit-tools/cli/tests/commands/log-list_test.ts +++ b/genkit-tools/cli/tests/commands/log-list_test.ts @@ -156,6 +156,8 @@ describe('log:list command', () => { logs: [ { logId: 'log-123', + traceId: 'trace-123', + spanId: 'span-123', timestamp: 1718302195000, severityText: 'WARN', body: 'A'.repeat(110), @@ -180,6 +182,8 @@ describe('log:list command', () => { ]); expect(console.log).toHaveBeenCalledWith('ID: log-123'); + expect(console.log).toHaveBeenCalledWith('Trace ID: trace-123'); + expect(console.log).toHaveBeenCalledWith('Span ID: span-123'); expect(console.log).toHaveBeenCalledWith('Severity: WARN'); expect(console.log).toHaveBeenCalledWith( `Time: ${new Date(1718302195000).toLocaleString()}` @@ -188,12 +192,12 @@ describe('log:list command', () => { expect(console.log).toHaveBeenCalledWith('Attrs: key=value'); }); - it('should output full logs as json in verbose mode', async () => { + it('should output logs in jsonl format', async () => { mockManager.listLogs.mockResolvedValue({ logs: [ { timestamp: 1718302195000, - body: 'Verbose log', + body: 'JSONL log', }, ], }); @@ -201,7 +205,8 @@ describe('log:list command', () => { await logList.parseAsync([ 'node', 'log:list', - '--verbose', + '--format', + 'jsonl', '--limit', '15', '--severity', @@ -220,12 +225,8 @@ describe('log:list command', () => { filter: undefined, }); - // In verbose mode, it dumps the full JSON using JSON.stringify(log, null, 2) expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('"body": "Verbose log"') - ); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('"timestamp": 1718302195000') + JSON.stringify({ timestamp: 1718302195000, body: 'JSONL log' }) ); });