From 9fe6d86d18df5046900a148edf669eb78258743f Mon Sep 17 00:00:00 2001 From: Max Edell Date: Fri, 11 Sep 2026 10:12:55 -0400 Subject: [PATCH 1/2] fix: opslog tweaks --- src/actions/ebs-sync/sync.js | 14 +++++++++----- src/actions/feeds/index.js | 3 ++- src/actions/meta-capi/index.js | 8 ++++---- src/actions/profile/index.js | 4 ++-- src/actions/recipe-notify/links.js | 4 +++- src/actions/recipe-notify/sync.js | 3 ++- src/actions/submit/index.js | 6 +++--- src/utils.js | 27 ++++++++++++++++++++++++--- 8 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/actions/ebs-sync/sync.js b/src/actions/ebs-sync/sync.js index 0033e05..5b54fe9 100644 --- a/src/actions/ebs-sync/sync.js +++ b/src/actions/ebs-sync/sync.js @@ -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 @@ -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); @@ -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; } diff --git a/src/actions/feeds/index.js b/src/actions/feeds/index.js index 0e5bd3c..83d7909 100644 --- a/src/actions/feeds/index.js +++ b/src/actions/feeds/index.js @@ -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 @@ -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}`; diff --git a/src/actions/meta-capi/index.js b/src/actions/meta-capi/index.js index 460a62d..ea72da7 100644 --- a/src/actions/meta-capi/index.js +++ b/src/actions/meta-capi/index.js @@ -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; @@ -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, @@ -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 { diff --git a/src/actions/profile/index.js b/src/actions/profile/index.js index c980a38..907a393 100644 --- a/src/actions/profile/index.js +++ b/src/actions/profile/index.js @@ -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'; @@ -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 { diff --git a/src/actions/recipe-notify/links.js b/src/actions/recipe-notify/links.js index f17176a..3577468 100644 --- a/src/actions/recipe-notify/links.js +++ b/src/actions/recipe-notify/links.js @@ -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 @@ -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) => { diff --git a/src/actions/recipe-notify/sync.js b/src/actions/recipe-notify/sync.js index 4f7ed7e..32f11bb 100644 --- a/src/actions/recipe-notify/sync.js +++ b/src/actions/recipe-notify/sync.js @@ -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 = { @@ -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(() => {}); diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js index 4f721d3..43fc0a5 100644 --- a/src/actions/submit/index.js +++ b/src/actions/submit/index.js @@ -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'; @@ -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(); @@ -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)); } } diff --git a/src/utils.js b/src/utils.js index 9c5f34f..cf79fdb 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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} [body] + * @param {number} statusCode + * @param {string} xError + * @param {string | Record} [body] * @returns {Error & { response: RuntimeResponse }} the error object, as returned from Runtime function */ export function errorWithResponse(message, statusCode, xError, body) { From d511ae92f084a0f48fb18eca7a02cd5a0d22cec3 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Fri, 11 Sep 2026 11:29:43 -0400 Subject: [PATCH 2/2] chore: tests --- test/utils.test.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/test/utils.test.js b/test/utils.test.js index ec63b18..c1e8256 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -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', () => { @@ -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' }); + }); +});