Skip to content

Commit 0317a1d

Browse files
jesseturner21claude
andcommitted
feat: extract readable messages from structured OTel and JSON logs
formatLogLine now parses structured log lines and extracts human-readable content instead of dumping raw JSON. Handles plain text, simple JSON (level + message), and OTel format (severityText + body) including nested content arrays and input/output message pairs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ef1e369 commit 0317a1d

2 files changed

Lines changed: 231 additions & 8 deletions

File tree

src/cli/commands/logs/__tests__/action.test.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { detectMode, formatLogLine, resolveAgentContext } from '../action';
1+
import { detectMode, extractReadableMessage, formatLogLine, resolveAgentContext } from '../action';
22
import type { LogsContext } from '../action';
33
import { describe, expect, it } from 'vitest';
44

@@ -20,21 +20,110 @@ describe('detectMode', () => {
2020
});
2121
});
2222

23-
describe('formatLogLine', () => {
24-
const event = { timestamp: 1709391000000, message: 'Hello world' };
23+
describe('extractReadableMessage', () => {
24+
it('returns plain text as-is', () => {
25+
const result = extractReadableMessage('WARNING: Invalid HTTP request received.');
26+
expect(result.message).toBe('WARNING: Invalid HTTP request received.');
27+
expect(result.level).toBeUndefined();
28+
});
29+
30+
it('extracts level and message from simple JSON', () => {
31+
const raw = JSON.stringify({
32+
timestamp: '2026-03-03T21:49:11Z',
33+
level: 'INFO',
34+
message: 'Invoking Agent.....',
35+
logger: 'bedrock_agentcore.app',
36+
});
37+
const result = extractReadableMessage(raw);
38+
expect(result.level).toBe('INFO');
39+
expect(result.message).toBe('Invoking Agent.....');
40+
});
41+
42+
it('extracts string body from OTel JSON', () => {
43+
const raw = JSON.stringify({
44+
scope: { name: 'bedrock_agentcore.app' },
45+
severityText: 'INFO',
46+
body: 'Returning streaming response (generator) (0.000s)',
47+
traceId: 'abc123',
48+
});
49+
const result = extractReadableMessage(raw);
50+
expect(result.level).toBe('INFO');
51+
expect(result.message).toBe('Returning streaming response (generator) (0.000s)');
52+
});
2553

26-
it('formats human-readable line with timestamp', () => {
54+
it('extracts text from OTel body.content array', () => {
55+
const raw = JSON.stringify({ severityText: '', body: { content: [{ text: 'hello' }] }, traceId: 'abc' });
56+
const result = extractReadableMessage(raw);
57+
expect(result.message).toBe('hello');
58+
});
59+
60+
it('extracts assistant response from OTel body.message.content', () => {
61+
const raw = JSON.stringify({
62+
severityText: '',
63+
body: {
64+
message: { content: [{ text: 'Hello! How can I help you today?' }], role: 'assistant' },
65+
index: 0,
66+
finish_reason: 'end_turn',
67+
},
68+
});
69+
const result = extractReadableMessage(raw);
70+
expect(result.message).toBe('[assistant] Hello! How can I help you today?');
71+
});
72+
73+
it('extracts from OTel input/output messages', () => {
74+
const raw = JSON.stringify({
75+
severityText: '',
76+
body: {
77+
output: { messages: [{ content: { message: '[{"text": "Hi there!"}]' }, role: 'assistant' }] },
78+
input: { messages: [{ content: { content: '[{"text": "hello"}]' }, role: 'user' }] },
79+
},
80+
});
81+
const result = extractReadableMessage(raw);
82+
expect(result.message).toContain('[user] hello');
83+
expect(result.message).toContain('[assistant] Hi there!');
84+
});
85+
86+
it('handles invalid JSON gracefully', () => {
87+
const result = extractReadableMessage('{not valid json}');
88+
expect(result.message).toBe('{not valid json}');
89+
});
90+
});
91+
92+
describe('formatLogLine', () => {
93+
it('formats plain text with timestamp', () => {
94+
const event = { timestamp: 1709391000000, message: 'Hello world' };
2795
const line = formatLogLine(event, false);
2896
expect(line).toContain('Hello world');
2997
expect(line).toContain('2024-03-02');
3098
});
3199

32-
it('formats JSON line', () => {
100+
it('formats structured log with level tag', () => {
101+
const event = {
102+
timestamp: 1709391000000,
103+
message: JSON.stringify({ level: 'WARN', message: 'something happened' }),
104+
};
105+
const line = formatLogLine(event, false);
106+
expect(line).toContain('WARN');
107+
expect(line).toContain('something happened');
108+
expect(line).not.toContain('"level"');
109+
});
110+
111+
it('formats JSON output with extracted fields', () => {
112+
const event = { timestamp: 1709391000000, message: JSON.stringify({ level: 'INFO', message: 'test message' }) };
33113
const line = formatLogLine(event, true);
34114
const parsed = JSON.parse(line);
35-
expect(parsed.message).toBe('Hello world');
115+
expect(parsed.level).toBe('INFO');
116+
expect(parsed.message).toBe('test message');
36117
expect(parsed.timestamp).toBeDefined();
37118
});
119+
120+
it('formats JSON output for plain text messages', () => {
121+
const event = { timestamp: 1709391000000, message: 'plain text' };
122+
const line = formatLogLine(event, true);
123+
const parsed = JSON.parse(line);
124+
expect(parsed.message).toBe('plain text');
125+
expect(parsed.level).toBeUndefined();
126+
});
38127
});
39128

40129
describe('resolveAgentContext', () => {

src/cli/commands/logs/action.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,149 @@ export function detectMode(options: LogsOptions): 'stream' | 'search' {
4646
return 'stream';
4747
}
4848

49+
interface TextEntry {
50+
text?: string;
51+
}
52+
53+
interface OTelMessage {
54+
content?: { content?: string; message?: string };
55+
role?: string;
56+
}
57+
58+
interface OTelBody {
59+
content?: TextEntry[];
60+
message?: { content?: TextEntry[]; role?: string };
61+
output?: { messages?: OTelMessage[] };
62+
input?: { messages?: OTelMessage[] };
63+
}
64+
65+
interface SimpleLogEntry {
66+
level?: string;
67+
message?: string;
68+
}
69+
70+
interface OTelLogEntry {
71+
severityText?: string;
72+
body?: string | OTelBody;
73+
}
74+
75+
function extractTexts(entries: TextEntry[]): string[] {
76+
return entries.filter(c => typeof c.text === 'string').map(c => c.text!);
77+
}
78+
79+
function extractFromIOMessages(messages: OTelMessage[], field: 'content' | 'message'): string[] {
80+
const parts: string[] = [];
81+
for (const msg of messages) {
82+
const raw = field === 'content' ? msg.content?.content : msg.content?.message;
83+
if (typeof raw !== 'string') continue;
84+
const role = typeof msg.role === 'string' ? msg.role : 'unknown';
85+
try {
86+
const arr: unknown = JSON.parse(raw);
87+
if (Array.isArray(arr)) {
88+
const texts = extractTexts(arr as TextEntry[]);
89+
if (texts.length) {
90+
parts.push(`[${role}] ${texts.join(' ')}`);
91+
continue;
92+
}
93+
}
94+
parts.push(`[${role}] ${raw}`);
95+
} catch {
96+
parts.push(`[${role}] ${raw}`);
97+
}
98+
}
99+
return parts;
100+
}
101+
102+
/**
103+
* Extract a human-readable message from a structured log line.
104+
* Handles plain text, simple JSON ({"level","message"}), and OTel JSON ({"severityText","body"}).
105+
*/
106+
export function extractReadableMessage(raw: string): { level?: string; message: string } {
107+
const trimmed = raw.trim();
108+
if (!trimmed.startsWith('{')) {
109+
return { message: trimmed };
110+
}
111+
112+
try {
113+
const parsed: unknown = JSON.parse(trimmed);
114+
if (typeof parsed !== 'object' || parsed === null) {
115+
return { message: trimmed };
116+
}
117+
118+
const simple = parsed as SimpleLogEntry;
119+
120+
// Simple JSON format: {"level": "INFO", "message": "..."}
121+
if (typeof simple.message === 'string' && typeof simple.level === 'string') {
122+
return { level: simple.level, message: simple.message };
123+
}
124+
125+
// OTel format: {"severityText": "INFO", "body": "..." | {...}}
126+
const otel = parsed as OTelLogEntry;
127+
if (otel.body !== undefined) {
128+
const level = typeof otel.severityText === 'string' && otel.severityText ? otel.severityText : undefined;
129+
const body = otel.body;
130+
131+
// String body
132+
if (typeof body === 'string') {
133+
return { level, message: body };
134+
}
135+
136+
// Object body with content array: {"content": [{"text": "..."}]}
137+
if (Array.isArray(body.content)) {
138+
const texts = extractTexts(body.content);
139+
if (texts.length > 0) {
140+
return { level, message: texts.join(' ') };
141+
}
142+
}
143+
144+
// Object body with message.content: {"message": {"content": [{"text": "..."}]}}
145+
if (body.message?.content && Array.isArray(body.message.content)) {
146+
const texts = extractTexts(body.message.content);
147+
if (texts.length > 0) {
148+
const role = body.message.role ? `[${body.message.role}]` : '';
149+
return { level, message: `${role} ${texts.join(' ')}`.trim() };
150+
}
151+
}
152+
153+
// Object body with stringified content in input/output messages
154+
if (body.output?.messages ?? body.input?.messages) {
155+
const parts: string[] = [
156+
...extractFromIOMessages(body.input?.messages ?? [], 'content'),
157+
...extractFromIOMessages(body.output?.messages ?? [], 'message'),
158+
];
159+
if (parts.length > 0) {
160+
return { level, message: parts.join(' | ') };
161+
}
162+
}
163+
164+
// Fallback: stringify the body
165+
return { level, message: JSON.stringify(body) };
166+
}
167+
168+
// Unrecognized JSON — return as-is
169+
return { message: trimmed };
170+
} catch {
171+
return { message: trimmed };
172+
}
173+
}
174+
49175
/**
50176
* Format a log event for display
51177
*/
52178
export function formatLogLine(event: { timestamp: number; message: string }, json: boolean): string {
53179
if (json) {
54-
return JSON.stringify({ timestamp: new Date(event.timestamp).toISOString(), message: event.message });
180+
const { level, message } = extractReadableMessage(event.message);
181+
return JSON.stringify({
182+
timestamp: new Date(event.timestamp).toISOString(),
183+
...(level ? { level } : {}),
184+
message,
185+
});
55186
}
187+
56188
const ts = new Date(event.timestamp).toISOString();
57-
return `${ts} ${event.message}`;
189+
const { level, message } = extractReadableMessage(event.message);
190+
const levelTag = level ? ` ${level.padEnd(5)}` : ' ';
191+
return `${ts}${levelTag} ${message}`;
58192
}
59193

60194
/**

0 commit comments

Comments
 (0)