Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 9 additions & 5 deletions src/actions/ebs-sync/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Core } from '@adobe/aio-sdk';
import { loadState, saveState, acquireLock, releaseLock } from './state.js';
import { getJournalEntries, getOrderJournalEntries, getOrder, updateOrderCustom, logOrderSync } from './commerce.js';
import { syncOrderToEbs, isRetriableError } from './ebs.js';
import { errorInfo } from '../../utils.js';

const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 3_000; // 3s, 6s, 9s
Expand Down Expand Up @@ -200,17 +201,19 @@ export async function run(params) {
if (body) syncLog.response = typeof body === 'string' ? body : JSON.stringify(body);
}
await logOrderSync(params, syncLog).catch((logErr) => {
log.warn(`[ebs-sync] Failed to log order-sync for ${orderId}: ${logErr.message}`);
log.warn(`[ebs-sync] Failed to log order-sync for ${orderId}`, errorInfo(logErr));
});

if (!isRetriableError(err)) {
log.warn(
`[ebs-sync] Order ${orderId} attempt ${attempt}/${MAX_RETRIES} failed with non-retriable error: ${err.message}`,
`[ebs-sync] Order ${orderId} attempt ${attempt}/${MAX_RETRIES} failed with non-retriable error`,
errorInfo(err),
);
break;
}
log.warn(
`[ebs-sync] Order ${orderId} attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`,
`[ebs-sync] Order ${orderId} attempt ${attempt}/${MAX_RETRIES} failed`,
errorInfo(err),
);
if (attempt < MAX_RETRIES) {
await sleep(RETRY_BASE_DELAY_MS * attempt);
Expand All @@ -230,11 +233,12 @@ export async function run(params) {

// Surface the failure on the order itself so it's visible downstream.
await updateOrderCustom(params, orderId, { syncError: describeSyncError(lastErr) }).catch((patchErr) => {
log.warn(`[ebs-sync] Failed to patch syncError for ${orderId}: ${patchErr.message}`);
log.warn(`[ebs-sync] Failed to patch syncError for ${orderId}`, errorInfo(patchErr));
});

log.error(
`[ebs-sync] Order ${orderId} failed after ${MAX_RETRIES} attempts. Halting.\n${errStack}`,
`[ebs-sync] Order ${orderId} failed after ${MAX_RETRIES} attempts. Halting.`,
errorInfo(lastErr),
);
break;
}
Expand Down
3 changes: 2 additions & 1 deletion src/actions/feeds/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Core } from '@adobe/aio-sdk';
import { fetchGmcFeed } from './source.js';
import { buildFeed } from './serialize.js';
import { PROVIDERS } from './providers/index.js';
import { errorInfo } from '../../utils.js';

const DEFAULT_LOCALE = 'us/en_us';
// Production public domain — serves the merchant feed and is reachable from the
Expand Down Expand Up @@ -116,7 +117,7 @@ export async function main(params) {
body,
};
} catch (err) {
log.error(`failed to build ${provider} feed for ${locale}: ${err.message}`);
log.error(`failed to build ${provider} feed for ${locale}`, errorInfo(err));
const statusCode = err.response?.error?.statusCode || 500;
const message = err.response?.error?.headers?.['x-error']
|| `failed to build feed: ${err.message}`;
Expand Down
8 changes: 4 additions & 4 deletions src/actions/meta-capi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Core } from '@adobe/aio-sdk';
import crypto from 'crypto';
import { getJournalEntries, getOrder } from '../ebs-sync/commerce.js';
import { init } from '@adobe/aio-lib-state';
import { errorInfo } from '../../utils.js';

/** @type {import('@adobe/aio-lib-state').AdobeState | null} */
let _client = null;
Expand Down Expand Up @@ -75,15 +76,14 @@ export async function main(params) {
});
await completeOrder(orderValue);
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
log.error('Error occurred while processing order', { orderId: orderValue, error: errMsg });
log.error('Error occurred while processing order', { orderId: orderValue, error: errorInfo(error) });
await failOrder(orderValue, error);
continue;
}
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
log.error('meta-capi consumer failed', { error: errMsg });
log.error('meta-capi consumer failed', { error: errorInfo(error) });
return jsonResponse(500, {
error: 'meta_capi_failed',
detail: errMsg,
Expand Down Expand Up @@ -304,7 +304,7 @@ async function sendToMeta(payload, params, log) {
body = await response.text();
} catch (networkError) {
const errMsg = networkError instanceof Error ? networkError.message : String(networkError);
log.error('[meta-capi] Network error calling Meta API', { url, error: errMsg });
log.error('[meta-capi] Network error calling Meta API', { url, error: errorInfo(networkError) });
throw new Error(`Meta API network error: ${errMsg}`);
}
try {
Expand Down
4 changes: 2 additions & 2 deletions src/actions/profile/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import makeContext from '../../context.js';
import { errorResponse } from '../../utils.js';
import { errorResponse, errorInfo } from '../../utils.js';
import { proxyFetch } from '../../proxy.js';

const PROD_ORIGIN = 'www.vitamix.com';
Expand Down Expand Up @@ -132,7 +132,7 @@ export async function main(params) {
smsOptInStatus: profile.SMSOptInStatus,
};
} catch (err) {
log.warn(`profile status fetch failed for ${email}: ${err.message}`);
log.warn(`profile status fetch failed for ${email}`, errorInfo(err));
}

return {
Expand Down
4 changes: 3 additions & 1 deletion src/actions/recipe-notify/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* notification time; if a recipe isn't found, its link is left null.
*/

import { errorInfo } from '../../utils.js';

/**
* The index also carries each recipe's `image` (the same asset used for the page
* `og:image`), so we resolve a small thumbnail URL from it here — no extra
Expand Down Expand Up @@ -45,7 +47,7 @@ export async function resolveLinks(ctx, recipes) {
log.warn(`[recipe-notify] query-index fetch failed: ${resp.status} — links will be omitted`);
}
} catch (err) {
log.warn(`[recipe-notify] query-index fetch error: ${err.message} — links will be omitted`);
log.warn('[recipe-notify] query-index fetch error — links will be omitted', errorInfo(err));
}

return recipes.map((r) => {
Expand Down
3 changes: 2 additions & 1 deletion src/actions/recipe-notify/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { getAccessToken } from '../../auth.js';
import { loadState, saveState, acquireLock, releaseLock } from './state.js';
import { fetchRecipes } from './recipes.js';
import { detectNewRecipes } from './detect.js';
import { errorInfo } from '../../utils.js';
import { buildDigest, sendDigest } from './notify.js';

const DEFAULTS = {
Expand Down Expand Up @@ -154,7 +155,7 @@ export async function run(params, options = {}) {
summary.newCursor = maxUpdated;
} catch (err) {
const message = err?.stack || String(err);
log.error(`[recipe-notify] Run failed — cursor not advanced.\n${message}`);
log.error('[recipe-notify] Run failed — cursor not advanced.', errorInfo(err));
summary.error = err.message;
if (!dryRun) {
await saveState({ status: 'error', lastError: message, lastRun: new Date().toISOString() }).catch(() => {});
Expand Down
6 changes: 3 additions & 3 deletions src/actions/submit/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { errorResponse } from '../../utils.js';
import { errorResponse, errorInfo } from '../../utils.js';
import { publishEvent } from '../../events.js';
import makeContext from '../../context.js';
import { createProductRegistration, queryOrder } from '../../ebs.js';
Expand Down Expand Up @@ -165,7 +165,7 @@ async function handleProductRegistration(ctx, formId, data) {
const marketingOptIn = data.marketingOptIn === true || data.marketingOptIn === 'yes';
const newsletterPromise = marketingOptIn
? callNewsletterApi(ctx, formId, { ...data, emailOptIn: true }).catch(err => {
log.warn(`newsletter subscription failed for product registration formId=${formId}: ${err.message}`);
log.warn(`newsletter subscription failed for product registration formId=${formId}`, errorInfo(err));
})
: Promise.resolve();

Expand Down Expand Up @@ -397,7 +397,7 @@ function logStageSubmission(ctx, formId, request, response) {
const { statusCode, body } = response?.error ?? response ?? {};
ctx.log.info(`[stage-submission] ${JSON.stringify({ formId, request, response: { statusCode, body } })}`);
} catch (err) {
ctx.log.warn(`failed to log stage submission for formId=${formId}: ${err.message}`);
ctx.log.warn(`failed to log stage submission for formId=${formId}`, errorInfo(err));
}
}

Expand Down
27 changes: 24 additions & 3 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,32 @@ export function errorResponse(statusCode, xError, body) {
}
}

/**
* Normalizes any thrown value into a consistent, loggable shape:
* `{ name, message, stack }`. Pass the result as the structured metadata
* argument of the Runtime logger (`log.error('msg', errorInfo(err))`) so every
* logged error carries its stack — which V8 embeds file/line/column into —
* instead of just its message.
*
* Safe on non-Error throwables (strings, plain objects): `name`/`stack` are
* simply omitted. Do NOT put the result in an HTTP response body returned to
* clients — stacks are internal diagnostics only.
*
* @param {*} error - an Error or any thrown value
* @returns {{ name?: string, message: string, stack?: string }}
*/
export function errorInfo(error) {
if (error instanceof Error) {
return { name: error.name, message: error.message, stack: error.stack };
}
return { message: String(error) };
}

/**
* @param {string} message - the error message
* @param {number} statusCode
* @param {string} xError
* @param {string | Record<string, unknown>} [body]
* @param {number} statusCode
* @param {string} xError
* @param {string | Record<string, unknown>} [body]
* @returns {Error & { response: RuntimeResponse }} the error object, as returned from Runtime function
*/
export function errorWithResponse(message, statusCode, xError, body) {
Expand Down
34 changes: 33 additions & 1 deletion test/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from '@jest/globals';
import { errorResponse } from '../src/utils.js';
import { errorResponse, errorInfo } from '../src/utils.js';

describe('errorResponse', () => {
test('returns error with statusCode and x-error header', () => {
Expand Down Expand Up @@ -52,3 +52,35 @@ describe('errorResponse', () => {
expect(errorResponse(422, 'validation', body).error.body).toEqual(body);
});
});

describe('errorInfo', () => {
test('extracts name, message, and stack from an Error instance', () => {
const err = new TypeError('boom');
const info = errorInfo(err);
expect(info.name).toBe('TypeError');
expect(info.message).toBe('boom');
expect(typeof info.stack).toBe('string');
expect(info.stack).toContain('boom');
});

test('preserves the subclass name for custom Error types', () => {
class SyncError extends Error {}
const err = new SyncError('nope');
err.name = 'SyncError';
expect(errorInfo(err)).toMatchObject({ name: 'SyncError', message: 'nope' });
});

test('non-Error throwable → only message (String-coerced), no name/stack', () => {
const info = errorInfo('just a string');
expect(info).toEqual({ message: 'just a string' });
expect(info).not.toHaveProperty('name');
expect(info).not.toHaveProperty('stack');
});

test('coerces non-Error objects and other primitives to a message string', () => {
expect(errorInfo({ code: 42 })).toEqual({ message: '[object Object]' });
expect(errorInfo(null)).toEqual({ message: 'null' });
expect(errorInfo(undefined)).toEqual({ message: 'undefined' });
expect(errorInfo(500)).toEqual({ message: '500' });
});
});
Loading