Repository navigation
fix(delete-user-data): log failed kit runs at ERROR severity #3282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: kits
Are you sure you want to change the base?
Changes from all commits
3b5a31b
bc30377
8e38970
5e15db8
872ad7b
0b803d6
c7ba4c0
3811b0b
e22e191
00b707e
30b9306
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -251,6 +251,46 @@ the `firebase init functions` template sets `maxInstances: 10`, so check your | |
| entry file. `setGlobalOptions` can't change concurrency, CPU or ingress, so an | ||
| ingress setting carried over by `ext:migrate` doesn't apply to the kit. | ||
|
|
||
| ### Failed runs show as HTTP 200 | ||
|
|
||
| On the extension's 1st gen functions, a thrown error or a timeout was logged as | ||
| `finished with status: 'error'` (or `'timeout'`). On 2nd gen, Cloud Run's | ||
| request log and its `request_count` metric record these event deliveries as | ||
| HTTP 200 even when the function fails. The | ||
| `cloudfunctions.googleapis.com/function/execution_count` metric still reports | ||
| them with a `status` of `error`, `timeout` or `crash` (out of memory), as it | ||
| did for the extension. | ||
|
|
||
| The kit also logs the error's stack trace for every failed run at ERROR | ||
| severity, as the extension did, and then lets the run fail. Error Reporting | ||
| groups it with the copy of the stack that the functions framework writes to | ||
| stderr, so each failure shows up twice in the same group. | ||
|
|
||
| To be told about failures, create a | ||
| [metric alert](https://cloud.google.com/monitoring/alerts/using-alerting-ui) | ||
| on `execution_count` with `metric.labels.status!="ok"`. It also counts each | ||
| event lost to an out-of-memory crash. Alternatively, create a | ||
| [log-based alert](https://cloud.google.com/logging/docs/alerting/log-based-alerts) | ||
| on ERROR severity, for example: | ||
|
|
||
| ``` | ||
| resource.type="cloud_run_revision" | ||
| resource.labels.service_name=~"^kit-<instance id>-" | ||
| (severity>=ERROR OR textPayload:"maximum request timeout") | ||
| ``` | ||
|
|
||
| A function can't log its own timeout. Cloud Run logs it as an INFO request log | ||
| that says "The request has been terminated because it has reached the maximum | ||
| request timeout", which the second clause above matches. On 2nd gen, the | ||
| timed-out run keeps going in the background, so you may still see its INFO | ||
| logs, such as `Successfully removed data for user`, minutes later. If it then | ||
| fails, the kit drops the error without logging it, as the extension recorded | ||
| nothing after a timeout, so it doesn't reach your alerts or Error Reporting. | ||
|
Comment on lines
+286
to
+288
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth saying that per-path failures inside |
||
|
|
||
| An out-of-memory crash logs one ERROR per crashed instance (`Memory limit of | ||
| 256 MiB exceeded…`), which the filter above catches. The event that was running | ||
| on that instance gets only an INFO, HTTP 200 request log with no message. | ||
|
|
||
| ### Events need an existing channel, and their source has changed | ||
|
|
||
| The extension created its Eventarc channel when you installed it with events | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,18 +17,23 @@ | |||||||||
| import { PubSub } from "@google-cloud/pubsub"; | ||||||||||
| import * as admin from "firebase-admin"; | ||||||||||
| import { getFirestore } from "firebase-admin/firestore"; | ||||||||||
| import type { Role } from "firebase-functions/v2"; | ||||||||||
| import type { CloudEvent, Role } from "firebase-functions/v2"; | ||||||||||
| import { requiresAPI, requiresRole } from "firebase-functions/v2"; | ||||||||||
| import { onUserDeleted } from "firebase-functions/v2/identity"; | ||||||||||
| import { onMessagePublished } from "firebase-functions/v2/pubsub"; | ||||||||||
| import { | ||||||||||
| type MessagePublishedData, | ||||||||||
| onMessagePublished, | ||||||||||
| } from "firebase-functions/v2/pubsub"; | ||||||||||
| import { assertParams, CONFIG_EXPRESSIONS, configFromEnv } from "./config"; | ||||||||||
| import * as events from "./events"; | ||||||||||
| import { getDatabaseUrl, resolveDeleteUserDataConfig } from "./export-config"; | ||||||||||
| import { | ||||||||||
| type DeleteMessageData, | ||||||||||
| type HandlerContext, | ||||||||||
| handleClear, | ||||||||||
| handleDeletion as runDeletion, | ||||||||||
| handleSearch as runSearch, | ||||||||||
| type SearchMessageData, | ||||||||||
| } from "./handlers"; | ||||||||||
| import * as logs from "./logs"; | ||||||||||
|
|
||||||||||
|
|
@@ -105,6 +110,54 @@ function getContext(): HandlerContext { | |||||||||
| return ctx; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Read when the functions are defined, as the SDK does. | ||||||||||
| const globalOptions = ( | ||||||||||
| globalThis as unknown as Record< | ||||||||||
| symbol, | ||||||||||
| { value?: { maxInstances?: unknown; timeoutSeconds?: unknown } } | undefined | ||||||||||
| > | ||||||||||
| )[Symbol.for("firebase-functions:v2:globalOptions")]?.value; | ||||||||||
|
|
||||||||||
| // The kit sets no timeout, so its functions take the global one, or 2nd gen's | ||||||||||
| // default of 60 seconds for event functions. A global value can be a param. | ||||||||||
| function timeoutMs(): number { | ||||||||||
| const option = globalOptions?.timeoutSeconds as | ||||||||||
| | number | ||||||||||
| | { value?: () => unknown } | ||||||||||
| | undefined; | ||||||||||
| const seconds = | ||||||||||
| typeof option === "object" && typeof option.value === "function" | ||||||||||
| ? option.value() | ||||||||||
| : option; | ||||||||||
| return (typeof seconds === "number" && seconds > 0 ? seconds : 60) * 1000; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // On 2nd gen, a handler that throws is still recorded as HTTP 200 in the | ||||||||||
| // request log, and the functions framework prints the stack to stderr with no | ||||||||||
| // severity. Log the error at ERROR severity so log-based alerts and Error | ||||||||||
| // Reporting see it, then rethrow so the invocation still counts as failed. | ||||||||||
|
Comment on lines
+137
to
+138
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error Reporting already picks up the framework's stderr stack, so this entry adds a second occurrence in the same group rather than making it visible there (the README says as much). Maybe:
Suggested change
|
||||||||||
| // | ||||||||||
| // A run that times out keeps going on 2nd gen and can throw minutes later. The | ||||||||||
| // extension recorded nothing after a timeout, so the kit swallows the error: | ||||||||||
| // Cloud Run has already logged the timeout and counted the run as one, and a | ||||||||||
| // rethrow would still put the stack on stderr, where Error Reporting picks it up. | ||||||||||
| function logFailures<E>( | ||||||||||
| handler: (event: E) => unknown | ||||||||||
| ): (event: E) => Promise<unknown> { | ||||||||||
| return async (event) => { | ||||||||||
| const started = Date.now(); | ||||||||||
| try { | ||||||||||
| return await handler(event); | ||||||||||
| } catch (err) { | ||||||||||
| if (Date.now() - started >= timeoutMs()) { | ||||||||||
| return; | ||||||||||
|
Comment on lines
+152
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this can drop a real failure. Could we drop the timeout check and always log and rethrow? That also removes |
||||||||||
| } | ||||||||||
| logs.unhandledError(err); | ||||||||||
| throw err; | ||||||||||
| } | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * The extension's 1st gen functions ran at 0.1666 vCPU, one event per | ||||||||||
| * instance, up to 3000 instances, with internal-only ingress. Without these | ||||||||||
|
|
@@ -120,12 +173,7 @@ function getContext(): HandlerContext { | |||||||||
| * Concurrency, CPU and ingress are always the extension's, as in the | ||||||||||
| * firestore-bigquery-export kit. | ||||||||||
| */ | ||||||||||
| const globalMaxInstances = ( | ||||||||||
| globalThis as unknown as Record< | ||||||||||
| symbol, | ||||||||||
| { value?: { maxInstances?: unknown } } | undefined | ||||||||||
| > | ||||||||||
| )[Symbol.for("firebase-functions:v2:globalOptions")]?.value?.maxInstances; | ||||||||||
| const globalMaxInstances = globalOptions?.maxInstances; | ||||||||||
|
|
||||||||||
| const EXTENSION_RUNTIME_OPTIONS = { | ||||||||||
| concurrency: 1, | ||||||||||
|
|
@@ -136,7 +184,7 @@ const EXTENSION_RUNTIME_OPTIONS = { | |||||||||
|
|
||||||||||
| export const clearData = onUserDeleted( | ||||||||||
| { ...EXTENSION_RUNTIME_OPTIONS, region: CONFIG_EXPRESSIONS.functionRegion }, | ||||||||||
| (event) => { | ||||||||||
| logFailures((event) => { | ||||||||||
| // The Auth event delivers no user record when the payload envelope is | ||||||||||
| // empty, so bail before getContext() rather than initialising the SDKs for | ||||||||||
| // nothing. | ||||||||||
|
|
@@ -146,16 +194,28 @@ export const clearData = onUserDeleted( | |||||||||
| return; | ||||||||||
| } | ||||||||||
| return handleClear(uid, getContext()); | ||||||||||
| } | ||||||||||
| }) | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| // Parse the message data the way the extension did, so a malformed message | ||||||||||
| // throws the same plain SyntaxError. `message.json` wraps it as "Unable to | ||||||||||
| // parse Pub/Sub message data as JSON". Error Reporting still groups the two | ||||||||||
| // apart, since the kit's stack frames are in node_modules. | ||||||||||
| function parseMessage<T>(event: CloudEvent<MessagePublishedData<T>>): T { | ||||||||||
| return JSON.parse( | ||||||||||
| Buffer.from(event.data.message.data, "base64").toString("utf8") | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export const handleSearch = onMessagePublished( | ||||||||||
| { | ||||||||||
| ...EXTENSION_RUNTIME_OPTIONS, | ||||||||||
| region: CONFIG_EXPRESSIONS.functionRegion, | ||||||||||
| topic: CONFIG_EXPRESSIONS.discoveryTopicName, | ||||||||||
| }, | ||||||||||
| (event) => runSearch(event.data.message.json, getContext()) | ||||||||||
| logFailures((event: CloudEvent<MessagePublishedData<SearchMessageData>>) => | ||||||||||
| runSearch(parseMessage(event), getContext()) | ||||||||||
| ) | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| export const handleDeletion = onMessagePublished( | ||||||||||
|
|
@@ -164,5 +224,7 @@ export const handleDeletion = onMessagePublished( | |||||||||
| region: CONFIG_EXPRESSIONS.functionRegion, | ||||||||||
| topic: CONFIG_EXPRESSIONS.deletionTopicName, | ||||||||||
| }, | ||||||||||
| (event) => runDeletion(event.data.message.json, getContext()) | ||||||||||
| logFailures((event: CloudEvent<MessagePublishedData<DeleteMessageData>>) => | ||||||||||
| runDeletion(parseMessage(event), getContext()) | ||||||||||
| ) | ||||||||||
| ); | ||||||||||
Uh oh!
There was an error while loading. Please reload this page.