diff --git a/kits/storage-resize-images/README.md b/kits/storage-resize-images/README.md index 1eb671c42..5fa139f9b 100644 --- a/kits/storage-resize-images/README.md +++ b/kits/storage-resize-images/README.md @@ -188,6 +188,18 @@ 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. +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 `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 18596f8ea..80d790047 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 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) { + logs.unhandledError(err); + throw err; + } + } ); diff --git a/kits/storage-resize-images/src/logs.ts b/kits/storage-resize-images/src/logs.ts index 91d8edbca..d229e28ab 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"; @@ -41,6 +42,11 @@ 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.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 new file mode 100644 index 000000000..c93c2285a --- /dev/null +++ b/kits/storage-resize-images/tests/unhandled-error.test.ts @@ -0,0 +1,98 @@ +/** + * 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"; +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(event: unknown = cloudEvent): Promise { + vi.resetModules(); + const { generateResizedImage } = await import("../src/index"); + await (generateResizedImage as unknown as (e: unknown) => Promise)( + 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(() => {}); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +test("an error loading config is logged at ERROR as its stack and rethrown", async () => { + vi.stubEnv("SHARP_OPTIONS", "{bad}"); + + await expectLoggedAtError(invoke(), 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 () => { + vi.stubEnv("SHARP_OPTIONS", "{}"); + + await expect(invoke()).resolves.toBeUndefined(); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.write).not.toHaveBeenCalled(); +});