fix(ingestion): log the real error and elapsed time when a scan submit gets no response

A submit that never gets a response is the one case where the error's identity is
the whole diagnosis, and it was the one thing the log did not carry:
JSON.stringify(new Error()) is `{}` because Error has no enumerable own properties,
so every no-response failure recorded `error: {}`. Pass it through safeError, which
the repo already uses for exactly this, and add the wall time across all attempts —
the attempt count alone cannot tell three 15s aborts from an instant rejection.

The test pins the serialization rather than the call: reverting to the raw error
fails with `expected '{}' not to be '{}'`.

The logging mock in the covering suite was hand-listed and silently dropped
safeError; spread the original instead, since that module is pure apart from
logToAxiom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
briant
2026-09-18 15:37:34 -06:00
parent dce428a492
commit 8afaa0cfef
2 changed files with 41 additions and 3 deletions
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type * as FliptClient from '~/server/flipt/client';
import type * as LoggingClient from '~/server/logging/client';
const {
mockDbWrite,
@@ -37,7 +38,12 @@ vi.mock('~/server/services/orchestrator/client', () => ({
internalOrchestratorClient: {},
}));
vi.mock('~/server/logging/client', () => ({ logToAxiom: mockLogToAxiom }));
// Spread the original: `safeError` is real, pure serialization logic the submit-failure
// log depends on, and a hand-listed factory silently dropped it.
vi.mock('~/server/logging/client', async (importOriginal) => ({
...(await importOriginal<typeof LoggingClient>()),
logToAxiom: mockLogToAxiom,
}));
vi.mock('~/shared/utils/air', () => ({ stringifyAIR: mockStringifyAIR }));
@@ -692,6 +698,30 @@ describe('createImageIngestionRequest', () => {
expect(mockLogToAxiom).toHaveBeenCalledWith(expect.objectContaining({ name, imageId: 1 }));
});
// A no-response submit is the one case where the error's identity IS the diagnosis, and
// `JSON.stringify(new Error())` is `{}` — so logging the error object recorded nothing.
it('serializes an Error from a failed submit instead of logging an empty object', async () => {
vi.useRealTimers();
mockSubmitWorkflow.mockResolvedValue({
data: undefined,
error: new Error('The operation was aborted due to timeout'),
response: undefined,
});
await createImageIngestionRequest({ imageId: 1, url: 'image-key' });
const logged = mockLogToAxiom.mock.calls.at(-1)?.[0];
expect(JSON.stringify(logged?.error)).not.toBe('{}');
expect(logged?.error).toEqual(
expect.objectContaining({
name: 'Error',
message: 'The operation was aborted due to timeout',
})
);
// Tells three 15s aborts apart from an instant rejection; the attempt count cannot.
expect(typeof logged?.elapsedMs).toBe('number');
});
it('submits one imageScanning step when the flag is on', async () => {
mockIsFlipt.mockResolvedValue(true);
await createImageIngestionRequest({ imageId: 1, url: 'image-key' });
@@ -14,7 +14,7 @@ import { getEdgeUrl } from '~/client-utils/edge-url';
import { dbRead, dbWrite } from '~/server/db/client';
import { env } from '~/env/server';
import { isProd } from '~/env/other';
import { logToAxiom } from '~/server/logging/client';
import { logToAxiom, safeError } from '~/server/logging/client';
import { internalOrchestratorClient } from '~/server/services/orchestrator/client';
import { submitWorkflowWithRetry } from '~/server/services/orchestrator/workflows';
import { hashContent } from '~/server/services/entity-moderation.service';
@@ -191,6 +191,7 @@ export async function createImageIngestionRequest({
// Re-submit transient infra failures (5xx / no-response), reusing the same
// `externalId` so a 500 that actually created the workflow isn't duplicated.
const submitStartedAt = Date.now();
const result = await submitWorkflowWithRetry(
{
client: internalOrchestratorClient,
@@ -222,7 +223,14 @@ export async function createImageIngestionRequest({
attempts,
responseStatus: response?.status,
serverTiming,
error,
// JSON.stringify(new Error()) is `{}` — Error carries no enumerable own
// properties — so logging the error directly recorded nothing at all, and
// a no-response submit is exactly the case where its name is the whole
// diagnosis (an abort is not a connection reset).
error: safeError(error),
// Wall time across every attempt. Distinguishes three per-attempt aborts
// (~45s) from a fast rejection, which the attempt count alone does not.
elapsedMs: Date.now() - submitStartedAt,
});
}