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
12 changes: 12 additions & 0 deletions kits/storage-resize-images/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion kits/storage-resize-images/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
);
6 changes: 6 additions & 0 deletions kits/storage-resize-images/src/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { format } from "node:util";
import { logger } from "firebase-functions";
import type { ResolvedResizeImagesConfig } from "./export-config";

Expand Down Expand Up @@ -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 =>
Expand Down
98 changes: 98 additions & 0 deletions kits/storage-resize-images/tests/unhandled-error.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("firebase-admin")>(
"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<void> {
vi.resetModules();
const { generateResizedImage } = await import("../src/index");
await (generateResizedImage as unknown as (e: unknown) => Promise<void>)(
event
);
}

async function expectLoggedAtError(
invocation: Promise<void>,
errorType: new (...args: never[]) => Error
): Promise<void> {
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();
});
Loading