Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions genkit-tools/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,6 +73,7 @@ const commands: Command[] = [
docsList,
docsRead,
docsSearch,
logList,
traceGet,
traceList,
];
Expand Down
157 changes: 157 additions & 0 deletions genkit-tools/cli/src/commands/log-list.ts
Original file line number Diff line number Diff line change
@@ -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 <number>', 'limit the number of returned logs', '15')
.option('--trace-id <id>', 'filter by trace ID')
.option('--span-id <id>', 'filter by span ID')
Comment on lines +43 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the behaviour if neither of these are specified? Will the most recent logs be printed? Worth documenting, and also worth mentioning that while traceId and spanId are optional, they are recommended to be provided (at least traceId). Not sure if there's any use case where that is not true....

.option(
'--severity <severity>',
'filter by severity (e.g., INFO, ERROR, WARNING)'
Comment on lines +46 to +47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this filtering to that exact severity or to and above? I think the latter is a common pattern for logs

)
.option(
'-v, --verbose',
'display the full JSON log instead of the preview message'
)
.option('--continuation-token <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,
};
Comment thread
shrutip90 marked this conversation as resolved.

const response = await manager.listLogs(listRequest);

if (!response || !response.logs || response.logs.length === 0) {
logger.info('No logs found.');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would this log to stdout? If so that may be distruptive in jsonl mode?

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();
}
Comment on lines +104 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Checking log.timestamp as a truthy value can lead to a bug if the timestamp is 0 (which is a valid timestamp representing the epoch). It is safer to check if the type is a number or if it is not null/undefined.

Suggested change
let time = 'unknown';
if (log.timestamp) {
time = new Date(log.timestamp).toLocaleString();
}
let time = 'unknown';
if (typeof log.timestamp === 'number') {
time = new Date(log.timestamp).toLocaleString();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1


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;
}
Comment thread
shrutip90 marked this conversation as resolved.

function formatAttributes(
attributes: Record<string, unknown> | 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;
}
4 changes: 3 additions & 1 deletion genkit-tools/cli/src/commands/trace-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading