Skip to content

Commit 7b0a52d

Browse files
authored
feat: Refactor(logging): centralize HTTP error logging (#336)
* feat: Refactor(logging): centralize HTTP error logging with logHttpError utility * fix: Do not log stack-trace for soft-fail
1 parent 85279ea commit 7b0a52d

File tree

7 files changed

+91
-39
lines changed

7 files changed

+91
-39
lines changed

src/actor/server.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ export function createExpressApp(
2626
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
2727

2828
function respondWithError(res: Response, error: unknown, logMessage: string, statusCode = 500) {
29-
log.error('Error in request', { logMessage, error });
29+
if (statusCode >= 500) {
30+
// Server errors (>= 500) - log as exception
31+
log.exception(error instanceof Error ? error : new Error(String(error)), 'Error in request', { logMessage, statusCode });
32+
} else {
33+
// Client errors (< 500) - log as softFail without stack trace
34+
const errorMessage = error instanceof Error ? error.message : String(error);
35+
log.softFail('Error in request', { logMessage, error: errorMessage, statusCode });
36+
}
3037
if (!res.headersSent) {
3138
res.status(statusCode).json({
3239
jsonrpc: '2.0',
@@ -105,7 +112,7 @@ export function createExpressApp(
105112
});
106113
const sessionId = new URL(req.url, `http://${req.headers.host}`).searchParams.get('sessionId');
107114
if (!sessionId) {
108-
log.error('No session ID provided in POST request');
115+
log.softFail('No session ID provided in POST request', { statusCode: 400 });
109116
res.status(400).json({
110117
jsonrpc: '2.0',
111118
error: {
@@ -120,7 +127,7 @@ export function createExpressApp(
120127
if (transport) {
121128
await transport.handlePostMessage(req, res);
122129
} else {
123-
log.error('Server is not connected to the client.');
130+
log.softFail('Server is not connected to the client.', { statusCode: 400 });
124131
res.status(400).json({
125132
jsonrpc: '2.0',
126133
error: {
@@ -217,7 +224,7 @@ export function createExpressApp(
217224
return;
218225
}
219226

220-
log.error('Session not found', { sessionId });
227+
log.softFail('Session not found', { sessionId, statusCode: 400 });
221228
res.status(400).send('Bad Request: Session not found').end();
222229
});
223230

src/mcp/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
55
import log from '@apify/log';
66

77
import { TimeoutError } from '../errors.js';
8+
import { logHttpError } from '../utils/logging.js';
89
import { ACTORIZED_MCP_CONNECTION_TIMEOUT_MSEC } from './const.js';
910
import { getMCPServerID } from './utils.js';
1011

@@ -40,8 +41,7 @@ export async function connectMCPClient(
4041
log.warning('Connection to MCP server using SSE transport timed out', { url });
4142
return null;
4243
}
43-
44-
log.error('Failed to connect to MCP server using SSE transport', { cause: error });
44+
logHttpError(error, 'Failed to connect to MCP server using SSE transport', { url, cause: error });
4545
throw error;
4646
}
4747
}

src/mcp/server.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { callActorGetDataset, defaultTools, getActorsAsTools, toolCategories } f
3939
import { decodeDotPropertyNames } from '../tools/utils.js';
4040
import type { ToolEntry } from '../types.js';
4141
import { buildActorResponseContent } from '../utils/actor-response.js';
42+
import { logHttpError } from '../utils/logging.js';
4243
import { buildMCPResponse } from '../utils/mcp.js';
4344
import { createProgressTracker } from '../utils/progress.js';
4445
import { cloneToolEntry, getToolPublicFieldOnly } from '../utils/tools.js';
@@ -483,7 +484,7 @@ export class ActorsMcpServer {
483484
// Validate token
484485
if (!apifyToken && !this.options.skyfireMode) {
485486
const msg = 'APIFY_TOKEN is required. It must be set in the environment variables or passed as a parameter in the body.';
486-
log.error(msg);
487+
log.softFail(msg, { statusCode: 400 });
487488
await this.server.sendLoggingMessage({ level: 'error', data: msg });
488489
throw new McpError(
489490
ErrorCode.InvalidParams,
@@ -507,7 +508,7 @@ export class ActorsMcpServer {
507508
.find((t) => t.name === name || (t.type === 'actor' && t.actorFullName === name));
508509
if (!tool) {
509510
const msg = `Tool ${name} not found. Available tools: ${this.listToolNames().join(', ')}`;
510-
log.error(msg);
511+
log.softFail(msg, { statusCode: 404 });
511512
await this.server.sendLoggingMessage({ level: 'error', data: msg });
512513
throw new McpError(
513514
ErrorCode.InvalidParams,
@@ -516,7 +517,7 @@ export class ActorsMcpServer {
516517
}
517518
if (!args) {
518519
const msg = `Missing arguments for tool ${name}`;
519-
log.error(msg);
520+
log.softFail(msg, { statusCode: 400 });
520521
await this.server.sendLoggingMessage({ level: 'error', data: msg });
521522
throw new McpError(
522523
ErrorCode.InvalidParams,
@@ -529,7 +530,7 @@ export class ActorsMcpServer {
529530
log.debug('Validate arguments for tool', { toolName: tool.name, input: args });
530531
if (!tool.ajvValidate(args)) {
531532
const msg = `Invalid arguments for tool ${tool.name}: args: ${JSON.stringify(args)} error: ${JSON.stringify(tool?.ajvValidate.errors)}`;
532-
log.error(msg);
533+
log.softFail(msg, { statusCode: 400 });
533534
await this.server.sendLoggingMessage({ level: 'error', data: msg });
534535
throw new McpError(
535536
ErrorCode.InvalidParams,
@@ -569,7 +570,9 @@ export class ActorsMcpServer {
569570
client = await connectMCPClient(tool.serverUrl, apifyToken);
570571
if (!client) {
571572
const msg = `Failed to connect to MCP server ${tool.serverUrl}`;
572-
log.error(msg);
573+
// Note: Timeout errors are already logged as warning in connectMCPClient
574+
// This is a fallback log for when connection fails (client-side issue)
575+
log.softFail(msg, { statusCode: 408 }); // 408 Request Timeout
573576
await this.server.sendLoggingMessage({ level: 'error', data: msg });
574577
return {
575578
content: [
@@ -663,15 +666,15 @@ export class ActorsMcpServer {
663666
}
664667
}
665668
} catch (error) {
666-
log.error('Error occurred while calling tool', { toolName: name, error });
669+
logHttpError(error, 'Error occurred while calling tool', { toolName: name });
667670
const errorMessage = (error instanceof Error) ? error.message : 'Unknown error';
668671
return buildMCPResponse([
669672
`Error calling tool ${name}: ${errorMessage}`,
670673
]);
671674
}
672675

673676
const msg = `Unknown tool: ${name}`;
674-
log.error(msg);
677+
log.softFail(msg, { statusCode: 404 });
675678
await this.server.sendLoggingMessage({
676679
level: 'error',
677680
data: msg,

src/tools/actor.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ensureOutputWithinCharLimit, getActorDefinitionStorageFieldNames, getAc
2424
import { fetchActorDetails } from '../utils/actor-details.js';
2525
import { buildActorResponseContent } from '../utils/actor-response.js';
2626
import { ajv } from '../utils/ajv.js';
27+
import { logHttpError } from '../utils/logging.js';
2728
import { buildMCPResponse } from '../utils/mcp.js';
2829
import type { ProgressTracker } from '../utils/progress.js';
2930
import type { JsonSchemaProperty } from '../utils/schema-generation.js';
@@ -84,7 +85,7 @@ export async function callActorGetDataset(
8485
try {
8586
await apifyClient.run(actorRun.id).abort({ gracefully: false });
8687
} catch (e) {
87-
log.error('Error aborting Actor run', { error: e, runId: actorRun.id });
88+
logHttpError(e, 'Error aborting Actor run', { runId: actorRun.id });
8889
}
8990
// Reject to stop waiting
9091
resolve(CLIENT_ABORT);
@@ -245,11 +246,9 @@ async function getMCPServersAsTools(
245246
}
246247
return await getMCPServerTools(actorId, client, mcpServerUrl);
247248
} catch (error) {
248-
// Server error - log and continue processing other actors
249-
log.error('Failed to connect to MCP server', {
249+
logHttpError(error, 'Failed to connect to MCP server', {
250250
actorFullName: actorInfo.actorDefinitionPruned.actorFullName,
251251
actorId,
252-
error,
253252
});
254253
return [];
255254
} finally {
@@ -294,10 +293,8 @@ export async function getActorsAsTools(
294293
webServerMcpPath: getActorMCPServerPath(actorDefinitionPruned),
295294
} as ActorInfo;
296295
} catch (error) {
297-
// Server error - log and continue processing other actors
298-
log.error('Failed to fetch Actor definition', {
296+
logHttpError(error, 'Failed to fetch Actor definition', {
299297
actorName: actorIdOrName,
300-
error,
301298
});
302299
return null;
303300
}
@@ -542,7 +539,7 @@ EXAMPLES:
542539

543540
return { content };
544541
} catch (error) {
545-
log.error('Failed to call Actor', { error, actorName, performStep });
542+
logHttpError(error, 'Failed to call Actor', { actorName, performStep });
546543
return buildMCPResponse([`Failed to call Actor '${actorName}': ${error instanceof Error ? error.message : String(error)}`]);
547544
}
548545
},

src/tools/fetch-apify-docs.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { z } from 'zod';
22
import zodToJsonSchema from 'zod-to-json-schema';
33

4-
import log from '@apify/log';
5-
64
import { HelperTools } from '../const.js';
75
import { fetchApifyDocsCache } from '../state.js';
86
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
97
import { ajv } from '../utils/ajv.js';
108
import { htmlToMarkdown } from '../utils/html-to-md.js';
9+
import { logHttpError } from '../utils/logging.js';
1110

1211
const fetchApifyDocsToolArgsSchema = z.object({
1312
url: z.string()
@@ -53,6 +52,11 @@ USAGE EXAMPLES:
5352
try {
5453
const response = await fetch(url);
5554
if (!response.ok) {
55+
// Create error object with statusCode for logHttpError
56+
const error = Object.assign(new Error(`HTTP ${response.status} ${response.statusText}`), {
57+
statusCode: response.status,
58+
});
59+
logHttpError(error, 'Failed to fetch the documentation page', { url, statusText: response.statusText });
5660
return {
5761
content: [{
5862
type: 'text',
@@ -66,7 +70,7 @@ USAGE EXAMPLES:
6670
// Use the URL without fragment as the key to avoid caching same page with different fragments
6771
fetchApifyDocsCache.set(urlWithoutFragment, markdown);
6872
} catch (error) {
69-
log.error('Failed to fetch the documentation page', { url, error });
73+
logHttpError(error, 'Failed to fetch the documentation page', { url });
7074
return {
7175
content: [{
7276
type: 'text',

src/utils/actor-details.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { Actor, Build } from 'apify-client';
22

3-
import log from '@apify/log';
4-
53
import type { ApifyClient } from '../apify-client.js';
64
import { filterSchemaProperties, shortenProperties } from '../tools/utils.js';
75
import type { IActorInputSchema } from '../types.js';
86
import { formatActorToActorCard } from './actor-card.js';
7+
import { logHttpError } from './logging.js';
98

109
// Keep the interface here since it is a self contained module
1110
export interface ActorDetailsResult {
@@ -38,19 +37,7 @@ export async function fetchActorDetails(apifyClient: ApifyClient, actorName: str
3837
readme: buildInfo.actorDefinition.readme || 'No README provided.',
3938
};
4039
} catch (error) {
41-
// Check if it's a 404 error (actor not found) - this is expected
42-
const is404 = typeof error === 'object'
43-
&& error !== null
44-
&& 'statusCode' in error
45-
&& (error as { statusCode?: number }).statusCode === 404;
46-
47-
if (is404) {
48-
// Log 404 errors at info level since they're expected (user may query non-existent actors)
49-
log.info(`Actor '${actorName}' not found`, { actorName });
50-
} else {
51-
// Log other errors at error level
52-
log.error(`Failed to fetch actor details for '${actorName}'`, { actorName, error });
53-
}
40+
logHttpError(error, `Failed to fetch actor details for '${actorName}'`, { actorName });
5441
return null;
5542
}
5643
}

src/utils/logging.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import log from '@apify/log';
2+
3+
/**
4+
* Safely extract HTTP status code from errors.
5+
* Checks both `statusCode` and `code` properties for compatibility.
6+
*/
7+
export function getHttpStatusCode(error: unknown): number | undefined {
8+
if (typeof error !== 'object' || error === null) {
9+
return undefined;
10+
}
11+
12+
// Check for statusCode property (used by apify-client)
13+
if ('statusCode' in error) {
14+
const { statusCode } = (error as { statusCode?: unknown });
15+
if (typeof statusCode === 'number' && statusCode >= 100 && statusCode < 600) {
16+
return statusCode;
17+
}
18+
}
19+
20+
// Check for code property (used by some error types)
21+
if ('code' in error) {
22+
const { code } = (error as { code?: unknown });
23+
if (typeof code === 'number' && code >= 100 && code < 600) {
24+
return code;
25+
}
26+
}
27+
28+
return undefined;
29+
}
30+
31+
/**
32+
* Logs HTTP errors based on status code, following apify-core pattern.
33+
* Uses `softFail` for status < 500 (API client errors) and `exception` for status >= 500 (API server errors).
34+
*
35+
* @param error - The error object
36+
* @param message - The log message
37+
* @param data - Additional data to include in the log
38+
*/
39+
export function logHttpError<T extends object>(error: unknown, message: string, data?: T): void {
40+
const statusCode = getHttpStatusCode(error);
41+
const errorMessage = error instanceof Error ? error.message : String(error);
42+
43+
if (statusCode !== undefined && statusCode < 500) {
44+
// Client errors (< 500) - log as softFail without stack trace
45+
log.softFail(message, { error: errorMessage, statusCode, ...data });
46+
} else if (statusCode !== undefined && statusCode >= 500) {
47+
// Server errors (>= 500) - log as exception with full error (includes stack trace)
48+
const errorObj = error instanceof Error ? error : new Error(String(error));
49+
log.exception(errorObj, message, { statusCode, ...data });
50+
} else {
51+
// No status code available - log as error
52+
log.error(message, { error, ...data });
53+
}
54+
}

0 commit comments

Comments
 (0)