From 7948ae492960d4cf1157c139d2a08c99a1bfcf86 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 20 Oct 2026 15:31:43 +0100 Subject: [PATCH 1/3] fix(storage-resize-images): log errors that escape the trigger at ERROR severity An error thrown before the resize, such as an invalid SHARP_OPTIONS, reached the logs only as unstructured stderr. Log it with logger.error and rethrow, and note in the README that 2nd gen failures should be alerted on from logs. --- kits/storage-resize-images/README.md | 7 ++ kits/storage-resize-images/src/index.ts | 11 ++- kits/storage-resize-images/src/logs.ts | 2 + .../tests/unhandled-error.test.ts | 79 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 kits/storage-resize-images/tests/unhandled-error.test.ts diff --git a/kits/storage-resize-images/README.md b/kits/storage-resize-images/README.md index 1eb671c421..7e82874c54 100644 --- a/kits/storage-resize-images/README.md +++ b/kits/storage-resize-images/README.md @@ -188,6 +188,13 @@ The function's service account needs `roles/eventarc.eventReceiver` and `roles/run.invoker` on top of the roles the extension asked for. The Firebase CLI grants these for you. +A failed event is not reliably marked as a failed request in 2nd gen, and the +trigger does not retry, so alert on the function's logs rather than its request +status. Any error the function raises is logged at ERROR severity. A timeout or +an out-of-memory kill stops the function before it can log anything; Cloud Run +records those as `The request has been terminated because it has reached the +maximum request timeout.` and `Memory limit of ... exceeded`. + ### Region `BUCKET_REGION` decides where the function deploys, as described below, rather diff --git a/kits/storage-resize-images/src/index.ts b/kits/storage-resize-images/src/index.ts index 18596f8eae..40d5268c3d 100644 --- a/kits/storage-resize-images/src/index.ts +++ b/kits/storage-resize-images/src/index.ts @@ -110,5 +110,14 @@ export const generateResizedImage = onObjectFinalized( concurrency: 1, cpu: "gcf_gen1", }, - (event) => handleObjectFinalized(event, getContext()) + async (event) => { + // 2nd gen request status does not reliably mark a failed event, so this + // structured ERROR entry is what log-based alerts can match on. + try { + await handleObjectFinalized(event, getContext()); + } catch (err) { + logs.unhandledError(err); + throw err; + } + } ); diff --git a/kits/storage-resize-images/src/logs.ts b/kits/storage-resize-images/src/logs.ts index 91d8edbcad..43ec72fbd1 100644 --- a/kits/storage-resize-images/src/logs.ts +++ b/kits/storage-resize-images/src/logs.ts @@ -41,6 +41,8 @@ export const error = (err: unknown): void => export const errorDeleting = (err: unknown): void => logger.warn("Error when deleting files", err); export const failed = (): void => logger.error("Failed execution of extension"); +export const unhandledError = (err: unknown): void => + logger.error("Unhandled error in generateResizedImage", err); export const imageAlreadyResized = (): void => logger.log("File is already a resized image, no processing is required"); export const imageFailedAttempt = (): void => diff --git a/kits/storage-resize-images/tests/unhandled-error.test.ts b/kits/storage-resize-images/tests/unhandled-error.test.ts new file mode 100644 index 0000000000..06477cd5fd --- /dev/null +++ b/kits/storage-resize-images/tests/unhandled-error.test.ts @@ -0,0 +1,79 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from "firebase-functions/v2"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +vi.mock("firebase-admin", async () => { + const actual = await vi.importActual( + "firebase-admin" + ); + return { + ...actual, + apps: [], + initializeApp: vi.fn(), + storage: vi.fn(() => ({ bucket: vi.fn(() => ({})) })), + }; +}); + +const cloudEvent = { + specversion: "1.0", + id: "event-1", + source: "//storage.googleapis.com/projects/_/buckets/demo-bucket", + type: "google.cloud.storage.object.v1.finalized", + time: "2026-01-01T00:00:00.000Z", + subject: "objects/notes.txt", + data: { bucket: "demo-bucket", name: "notes.txt", contentType: "text/plain" }, +}; + +async function invoke(): Promise { + vi.resetModules(); + const { generateResizedImage } = await import("../src/index"); + await (generateResizedImage as unknown as (e: unknown) => Promise)( + cloudEvent + ); +} + +beforeEach(() => { + vi.stubEnv("IMG_BUCKET", "demo-bucket"); + vi.stubEnv("IMG_SIZES", "200x200"); + vi.spyOn(logger, "error").mockImplementation(() => {}); + vi.spyOn(logger, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +test("an error thrown outside the resize is logged at ERROR and rethrown", async () => { + vi.stubEnv("SHARP_OPTIONS", "{bad}"); + + await expect(invoke()).rejects.toThrow(SyntaxError); + + expect(logger.error).toHaveBeenCalledWith( + "Unhandled error in generateResizedImage", + expect.any(SyntaxError) + ); +}); + +test("an event that completes logs no error", async () => { + vi.stubEnv("SHARP_OPTIONS", "{}"); + + await expect(invoke()).resolves.toBeUndefined(); + + expect(logger.error).not.toHaveBeenCalled(); +}); From 8157c0f74735610eb2fc15f95b819c9e9d8ee646 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 26 Oct 2026 15:00:11 +0000 Subject: [PATCH 2/3] Update kits/storage-resize-images/tests/unhandled-error.test.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- kits/storage-resize-images/tests/unhandled-error.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kits/storage-resize-images/tests/unhandled-error.test.ts b/kits/storage-resize-images/tests/unhandled-error.test.ts index 06477cd5fd..dcf6503fe8 100644 --- a/kits/storage-resize-images/tests/unhandled-error.test.ts +++ b/kits/storage-resize-images/tests/unhandled-error.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { logger } from "firebase-functions/v2"; +import { logger } from "firebase-functions"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; vi.mock("firebase-admin", async () => { From 31fe03d9b38bebcfcf140ca755f14545598fd8b8 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 27 Oct 2026 13:32:20 +0000 Subject: [PATCH 3/3] fix(storage-resize-images): log escaped errors as the bare stack Matches the extension's ERROR entry so Error Reporting groups each failure once. The README now says that failed events are always recorded as HTTP 200 and that timeouts are never logged at ERROR. --- kits/storage-resize-images/README.md | 17 +++++---- kits/storage-resize-images/src/index.ts | 4 +-- kits/storage-resize-images/src/logs.ts | 6 +++- .../tests/unhandled-error.test.ts | 35 ++++++++++++++----- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/kits/storage-resize-images/README.md b/kits/storage-resize-images/README.md index 7e82874c54..5fa139f9bb 100644 --- a/kits/storage-resize-images/README.md +++ b/kits/storage-resize-images/README.md @@ -188,12 +188,17 @@ The function's service account needs `roles/eventarc.eventReceiver` and `roles/run.invoker` on top of the roles the extension asked for. The Firebase CLI grants these for you. -A failed event is not reliably marked as a failed request in 2nd gen, and the -trigger does not retry, so alert on the function's logs rather than its request -status. Any error the function raises is logged at ERROR severity. A timeout or -an out-of-memory kill stops the function before it can log anything; Cloud Run -records those as `The request has been terminated because it has reached the -maximum request timeout.` and `Memory limit of ... exceeded`. +In 2nd gen every event is recorded as HTTP 200, including thrown errors, +timeouts and out-of-memory kills, and the trigger does not retry, so alert on +the function's logs rather than its request status. Any error the function +raises is logged at ERROR severity with its stack. A timeout or an +out-of-memory kill stops the function before it can log an error: + +- An out-of-memory kill is logged at ERROR in the `varlog/system` log as + `Memory limit of ... exceeded`. +- A timeout is never logged at ERROR, so an alert on ERROR alone misses it. + Cloud Run logs it at INFO in the requests log as + `The request has been terminated because it has reached the maximum request timeout.` ### Region diff --git a/kits/storage-resize-images/src/index.ts b/kits/storage-resize-images/src/index.ts index 40d5268c3d..80d7900471 100644 --- a/kits/storage-resize-images/src/index.ts +++ b/kits/storage-resize-images/src/index.ts @@ -111,8 +111,8 @@ export const generateResizedImage = onObjectFinalized( cpu: "gcf_gen1", }, async (event) => { - // 2nd gen request status does not reliably mark a failed event, so this - // structured ERROR entry is what log-based alerts can match on. + // 2nd gen records every event as HTTP 200, even a failed one, so this + // ERROR entry is what log-based alerts can match on. try { await handleObjectFinalized(event, getContext()); } catch (err) { diff --git a/kits/storage-resize-images/src/logs.ts b/kits/storage-resize-images/src/logs.ts index 43ec72fbd1..d229e28ab1 100644 --- a/kits/storage-resize-images/src/logs.ts +++ b/kits/storage-resize-images/src/logs.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { format } from "node:util"; import { logger } from "firebase-functions"; import type { ResolvedResizeImagesConfig } from "./export-config"; @@ -42,7 +43,10 @@ export const errorDeleting = (err: unknown): void => logger.warn("Error when deleting files", err); export const failed = (): void => logger.error("Failed execution of extension"); export const unhandledError = (err: unknown): void => - logger.error("Unhandled error in generateResizedImage", err); + logger.write({ + severity: "ERROR", + message: (err instanceof Error && err.stack) || format(err), + }); export const imageAlreadyResized = (): void => logger.log("File is already a resized image, no processing is required"); export const imageFailedAttempt = (): void => diff --git a/kits/storage-resize-images/tests/unhandled-error.test.ts b/kits/storage-resize-images/tests/unhandled-error.test.ts index dcf6503fe8..c93c2285ad 100644 --- a/kits/storage-resize-images/tests/unhandled-error.test.ts +++ b/kits/storage-resize-images/tests/unhandled-error.test.ts @@ -39,17 +39,33 @@ const cloudEvent = { data: { bucket: "demo-bucket", name: "notes.txt", contentType: "text/plain" }, }; -async function invoke(): Promise { +async function invoke(event: unknown = cloudEvent): Promise { vi.resetModules(); const { generateResizedImage } = await import("../src/index"); await (generateResizedImage as unknown as (e: unknown) => Promise)( - cloudEvent + event ); } +async function expectLoggedAtError( + invocation: Promise, + errorType: new (...args: never[]) => Error +): Promise { + const err = await invocation.then( + () => expect.fail("expected the trigger to throw"), + (e: unknown) => e + ); + expect(err).toBeInstanceOf(errorType); + expect(logger.write).toHaveBeenCalledWith({ + severity: "ERROR", + message: (err as Error).stack, + }); +} + beforeEach(() => { vi.stubEnv("IMG_BUCKET", "demo-bucket"); vi.stubEnv("IMG_SIZES", "200x200"); + vi.spyOn(logger, "write").mockImplementation(() => {}); vi.spyOn(logger, "error").mockImplementation(() => {}); vi.spyOn(logger, "log").mockImplementation(() => {}); }); @@ -59,15 +75,17 @@ afterEach(() => { vi.restoreAllMocks(); }); -test("an error thrown outside the resize is logged at ERROR and rethrown", async () => { +test("an error loading config is logged at ERROR as its stack and rethrown", async () => { vi.stubEnv("SHARP_OPTIONS", "{bad}"); - await expect(invoke()).rejects.toThrow(SyntaxError); + await expectLoggedAtError(invoke(), SyntaxError); +}); - expect(logger.error).toHaveBeenCalledWith( - "Unhandled error in generateResizedImage", - expect.any(SyntaxError) - ); +test("an error after config loads is logged at ERROR as its stack and rethrown", async () => { + vi.stubEnv("SHARP_OPTIONS", "{}"); + const { name: _name, ...data } = cloudEvent.data; + + await expectLoggedAtError(invoke({ ...cloudEvent, data }), TypeError); }); test("an event that completes logs no error", async () => { @@ -76,4 +94,5 @@ test("an event that completes logs no error", async () => { await expect(invoke()).resolves.toBeUndefined(); expect(logger.error).not.toHaveBeenCalled(); + expect(logger.write).not.toHaveBeenCalled(); });