Skip to content

Commit 0d29262

Browse files
authored
Add mrt tail-logs command for real-time log streaming (#125)
* add mrt tail-logs command for real-time log streaming Stream application logs from MRT environments with level filtering, regex search with highlighting, color output, and JSON/NDJSON mode. * update changeset with filtering and highlighting details
1 parent 0ba0786 commit 0d29262

File tree

13 files changed

+1219
-257
lines changed

13 files changed

+1219
-257
lines changed

.changeset/mrt-tail-logs.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@salesforce/b2c-cli': minor
3+
'@salesforce/b2c-tooling-sdk': minor
4+
---
5+
6+
Add `mrt tail-logs` command to stream real-time application logs from Managed Runtime environments. Supports level filtering, regex search with match highlighting, and JSON output.

packages/b2c-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"@salesforce/dev-config": "^4.3.2",
3535
"@types/chai": "^4",
3636
"@types/mocha": "^10",
37-
"@types/node": "^18",
37+
"@types/node": "^22",
3838
"c8": "^10.1.3",
3939
"chai": "^4",
4040
"eslint": "^9",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {Flags, ux} from '@oclif/core';
7+
import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli';
8+
import {getProfile, tailMrtLogs, type MrtLogEntry} from '@salesforce/b2c-tooling-sdk/operations/mrt';
9+
import {t} from '../../i18n/index.js';
10+
import {formatMrtEntry, colorLevel, colorHighlight} from '../../utils/mrt-logs/index.js';
11+
12+
export default class MrtTailLogs extends MrtCommand<typeof MrtTailLogs> {
13+
static description = t(
14+
'commands.mrt.tail-logs.description',
15+
'Tail application logs from a Managed Runtime environment',
16+
);
17+
18+
static enableJsonFlag = true;
19+
20+
static examples = [
21+
'<%= config.bin %> <%= command.id %> -p my-storefront -e staging',
22+
'<%= config.bin %> <%= command.id %> -p my-storefront -e production --level ERROR --level WARN',
23+
'<%= config.bin %> <%= command.id %> -p my-storefront -e staging --json',
24+
'<%= config.bin %> <%= command.id %> -p my-storefront -e staging --search "timeout"',
25+
'<%= config.bin %> <%= command.id %> -p my-storefront -e staging --search "GET|POST"',
26+
];
27+
28+
static flags = {
29+
...MrtCommand.baseFlags,
30+
level: Flags.string({
31+
description: 'Filter by log level (ERROR, WARN, INFO, DEBUG, etc.)',
32+
multiple: true,
33+
}),
34+
search: Flags.string({
35+
char: 'g',
36+
description: 'Filter entries matching this regex pattern (case-insensitive)',
37+
}),
38+
'no-color': Flags.boolean({
39+
description: 'Disable colored output',
40+
default: false,
41+
}),
42+
};
43+
44+
async run(): Promise<void> {
45+
this.requireMrtCredentials();
46+
47+
const {mrtProject: project, mrtEnvironment: environment, mrtOrigin: origin} = this.resolvedConfig.values;
48+
49+
if (!project) {
50+
this.error(
51+
'MRT project is required. Provide --project flag, set SFCC_MRT_PROJECT, or set mrtProject in dw.json.',
52+
);
53+
}
54+
if (!environment) {
55+
this.error(
56+
'MRT environment is required. Provide --environment flag, set SFCC_MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.',
57+
);
58+
}
59+
60+
const auth = this.getMrtAuth();
61+
const useColor = !this.flags['no-color'] && process.stdout.isTTY && !this.jsonEnabled();
62+
const levelFilter = this.flags.level;
63+
const searchFilter = this.flags.search;
64+
65+
// Compile search regex (case-insensitive, global for highlighting)
66+
let searchRegex: RegExp | undefined;
67+
if (searchFilter) {
68+
try {
69+
searchRegex = new RegExp(searchFilter, 'gi');
70+
} catch {
71+
this.error(`Invalid search pattern: "${searchFilter}". Must be a valid regular expression.`);
72+
}
73+
}
74+
75+
// Pre-compute level filter set
76+
const upperLevels = levelFilter && levelFilter.length > 0 ? new Set(levelFilter.map((l) => l.toUpperCase())) : null;
77+
78+
// Best-effort user email for WebSocket connection
79+
let user: string | undefined;
80+
try {
81+
const profile = await getProfile({origin}, auth);
82+
user = profile.email;
83+
} catch {
84+
// Non-fatal: proceed without user email
85+
}
86+
87+
if (!this.jsonEnabled()) {
88+
this.log(
89+
t('commands.mrt.tail-logs.connecting', 'Connecting to {{project}}/{{environment}} logs...', {
90+
project,
91+
environment,
92+
}),
93+
);
94+
95+
// Log active filters
96+
if (upperLevels) {
97+
const levels = useColor ? [...upperLevels].map((l) => colorLevel(l)).join(', ') : [...upperLevels].join(', ');
98+
this.log(t('commands.mrt.tail-logs.filterLevel', 'Filtering by level: {{levels}}', {levels}));
99+
}
100+
if (searchFilter) {
101+
const pattern = useColor ? colorHighlight(searchFilter) : searchFilter;
102+
this.log(t('commands.mrt.tail-logs.filterSearch', 'Filtering by pattern: {{pattern}}', {pattern}));
103+
}
104+
}
105+
106+
const {stop, done} = await tailMrtLogs(
107+
{
108+
projectSlug: project,
109+
environmentSlug: environment,
110+
origin,
111+
user,
112+
onEntry: (entry: MrtLogEntry) => {
113+
// Apply level filter
114+
if (upperLevels && (!entry.level || !upperLevels.has(entry.level.toUpperCase()))) return;
115+
116+
// Apply search filter (regex match against message and raw)
117+
if (searchRegex) {
118+
// Reset lastIndex since we reuse the global regex
119+
searchRegex.lastIndex = 0;
120+
const matchesMessage = searchRegex.test(entry.message);
121+
searchRegex.lastIndex = 0;
122+
const matchesRaw = searchRegex.test(entry.raw);
123+
if (!matchesMessage && !matchesRaw) return;
124+
// Reset for highlighting pass
125+
searchRegex.lastIndex = 0;
126+
}
127+
128+
if (this.jsonEnabled()) {
129+
ux.stdout(JSON.stringify(entry));
130+
} else {
131+
ux.stdout(formatMrtEntry(entry, {useColor, searchHighlight: searchRegex}));
132+
}
133+
},
134+
onConnect: () => {
135+
if (!this.jsonEnabled()) {
136+
this.log(t('commands.mrt.tail-logs.connected', 'Connected. Waiting for log entries...'));
137+
this.log(t('commands.mrt.tail-logs.interrupt', 'Press Ctrl+C to stop.\n'));
138+
}
139+
},
140+
onError: (error: Error) => {
141+
this.warn(t('commands.mrt.tail-logs.error', 'WebSocket error: {{message}}', {message: error.message}));
142+
},
143+
onClose: (_code: number, _reason: string) => {
144+
if (!this.jsonEnabled()) {
145+
this.log(t('commands.mrt.tail-logs.disconnected', '\nDisconnected from log stream.'));
146+
}
147+
},
148+
},
149+
auth,
150+
);
151+
152+
// Graceful shutdown on signals
153+
const handleSignal = (): void => {
154+
stop();
155+
};
156+
157+
process.on('SIGINT', handleSignal);
158+
process.on('SIGTERM', handleSignal);
159+
160+
await done;
161+
}
162+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
import type {MrtLogEntry} from '@salesforce/b2c-tooling-sdk/operations/mrt';
8+
9+
/**
10+
* ANSI color codes for log levels.
11+
* Matches the color scheme from logs/format.ts for consistency.
12+
*/
13+
const LEVEL_COLORS: Record<string, string> = {
14+
ERROR: '\u001B[31m', // Red
15+
FATAL: '\u001B[35m', // Magenta
16+
WARN: '\u001B[33m', // Yellow
17+
WARNING: '\u001B[33m', // Yellow
18+
INFO: '\u001B[36m', // Cyan
19+
DEBUG: '\u001B[90m', // Gray
20+
TRACE: '\u001B[90m', // Gray
21+
};
22+
23+
const RESET = '\u001B[0m';
24+
const DIM = '\u001B[2m';
25+
const BOLD = '\u001B[1m';
26+
const HIGHLIGHT = '\u001B[1;33m'; // Bold yellow for search matches
27+
28+
/**
29+
* Options for formatting an MRT log entry.
30+
*/
31+
export interface FormatMrtEntryOptions {
32+
/** Whether to use ANSI color codes. */
33+
useColor: boolean;
34+
/** Regex to highlight matches in the message. */
35+
searchHighlight?: RegExp;
36+
}
37+
38+
/**
39+
* Highlights regex matches in text with ANSI color codes.
40+
*/
41+
function highlightMatches(text: string, pattern: RegExp): string {
42+
return text.replace(pattern, (match) => `${HIGHLIGHT}${match}${RESET}`);
43+
}
44+
45+
/**
46+
* Colorizes a log level name using its level color + bold.
47+
*/
48+
export function colorLevel(level: string): string {
49+
const color = LEVEL_COLORS[level] || '';
50+
return `${color}${BOLD}${level}${RESET}`;
51+
}
52+
53+
/**
54+
* Colorizes text with the search highlight style (bold yellow).
55+
*/
56+
export function colorHighlight(text: string): string {
57+
return `${HIGHLIGHT}${text}${RESET}`;
58+
}
59+
60+
/**
61+
* Formats an MRT log entry for terminal display.
62+
*
63+
* Output format:
64+
* ```
65+
* LEVEL [timestamp] [shortRequestId]
66+
* message
67+
* ```
68+
*/
69+
export function formatMrtEntry(entry: MrtLogEntry, options: FormatMrtEntryOptions): string {
70+
const {useColor, searchHighlight} = options;
71+
const headerParts: string[] = [];
72+
73+
// Level first (most important for scanning)
74+
if (entry.level) {
75+
if (useColor) {
76+
const color = LEVEL_COLORS[entry.level] || '';
77+
headerParts.push(`${color}${BOLD}${entry.level}${RESET}`);
78+
} else {
79+
headerParts.push(entry.level);
80+
}
81+
}
82+
83+
// Timestamp
84+
if (entry.timestamp) {
85+
if (useColor) {
86+
headerParts.push(`${DIM}[${entry.timestamp}]${RESET}`);
87+
} else {
88+
headerParts.push(`[${entry.timestamp}]`);
89+
}
90+
}
91+
92+
// Short request ID
93+
if (entry.shortRequestId) {
94+
if (useColor) {
95+
headerParts.push(`${DIM}[${entry.shortRequestId}]${RESET}`);
96+
} else {
97+
headerParts.push(`[${entry.shortRequestId}]`);
98+
}
99+
}
100+
101+
const header = headerParts.join(' ');
102+
let message = entry.message.trimEnd();
103+
104+
// Highlight search matches in message
105+
if (useColor && searchHighlight) {
106+
message = highlightMatches(message, searchHighlight);
107+
}
108+
109+
return `${header}\n${message}\n`;
110+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
export * from './format.js';

0 commit comments

Comments
 (0)