diff --git a/docs/generate.sh b/docs/generate.sh index 468ee7f..0dd99ac 100755 --- a/docs/generate.sh +++ b/docs/generate.sh @@ -84,6 +84,11 @@ fi TITLE="Firebase Python SDK for Cloud Functions" PY_MODULES='firebase_functions firebase_functions.core + firebase_functions.alerts_fn + firebase_functions.alerts.app_distribution_fn + firebase_functions.alerts.billing_fn + firebase_functions.alerts.crashlytics_fn + firebase_functions.alerts.performance_fn firebase_functions.db_fn firebase_functions.eventarc_fn firebase_functions.https_fn diff --git a/samples/basic_alerts/.firebaserc b/samples/basic_alerts/.firebaserc new file mode 100644 index 0000000..ad27d4b --- /dev/null +++ b/samples/basic_alerts/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "python-functions-testing" + } +} diff --git a/samples/basic_alerts/.gitignore b/samples/basic_alerts/.gitignore new file mode 100644 index 0000000..dbb58ff --- /dev/null +++ b/samples/basic_alerts/.gitignore @@ -0,0 +1,66 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env diff --git a/samples/basic_alerts/__init__.py b/samples/basic_alerts/__init__.py new file mode 100644 index 0000000..2340b04 --- /dev/null +++ b/samples/basic_alerts/__init__.py @@ -0,0 +1,3 @@ +# Required to avoid a 'duplicate modules' mypy error +# in monorepos that have multiple main.py files. +# https://github.com/python/mypy/issues/4008 diff --git a/samples/basic_alerts/firebase.json b/samples/basic_alerts/firebase.json new file mode 100644 index 0000000..7bbd899 --- /dev/null +++ b/samples/basic_alerts/firebase.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "venv" + ] + } + ] +} diff --git a/samples/basic_alerts/functions/.gitignore b/samples/basic_alerts/functions/.gitignore new file mode 100644 index 0000000..34cef6b --- /dev/null +++ b/samples/basic_alerts/functions/.gitignore @@ -0,0 +1,13 @@ +# pyenv +.python-version + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Environments +.env +.venv +venv/ +venv.bak/ +__pycache__ diff --git a/samples/basic_alerts/functions/main.py b/samples/basic_alerts/functions/main.py new file mode 100644 index 0000000..6b0912e --- /dev/null +++ b/samples/basic_alerts/functions/main.py @@ -0,0 +1,81 @@ +"""Cloud function samples for Firebase Alerts.""" + +from firebase_functions import alerts_fn +from firebase_functions.alerts import app_distribution_fn +from firebase_functions.alerts import billing_fn +from firebase_functions.alerts import crashlytics_fn +from firebase_functions.alerts import performance_fn + + +@alerts_fn.on_alert_published(alert_type=alerts_fn.AlertType.BILLING_PLAN_UPDATE + ) +def onalertpublished( + alert: alerts_fn.AlertEvent[alerts_fn.FirebaseAlertData[ + billing_fn.PlanUpdatePayload]] +) -> None: + print(alert) + + +@app_distribution_fn.on_in_app_feedback_published() +def appdistributioninappfeedback( + alert: app_distribution_fn.InAppFeedbackEvent) -> None: + print(alert) + + +@app_distribution_fn.on_new_tester_ios_device_published() +def appdistributionnewrelease( + alert: app_distribution_fn.NewTesterDeviceEvent) -> None: + print(alert) + + +@billing_fn.on_plan_automated_update_published() +def billingautomatedplanupdate( + alert: billing_fn.BillingPlanAutomatedUpdateEvent) -> None: + print(alert) + + +@billing_fn.on_plan_update_published() +def billingplanupdate(alert: billing_fn.BillingPlanUpdateEvent) -> None: + print(alert) + + +@crashlytics_fn.on_new_fatal_issue_published() +def crashlyticsnewfatalissue( + alert: crashlytics_fn.CrashlyticsNewFatalIssueEvent) -> None: + print(alert) + + +@crashlytics_fn.on_new_nonfatal_issue_published() +def crashlyticsnewnonfatalissue( + alert: crashlytics_fn.CrashlyticsNewNonfatalIssueEvent) -> None: + print(alert) + + +@crashlytics_fn.on_new_anr_issue_published() +def crashlyticsnewanrissue( + alert: crashlytics_fn.CrashlyticsNewAnrIssueEvent) -> None: + print(alert) + + +@crashlytics_fn.on_regression_alert_published() +def crashlyticsregression( + alert: crashlytics_fn.CrashlyticsRegressionAlertEvent) -> None: + print(alert) + + +@crashlytics_fn.on_stability_digest_published() +def crashlyticsstabilitydigest( + alert: crashlytics_fn.CrashlyticsStabilityDigestEvent) -> None: + print(alert) + + +@crashlytics_fn.on_velocity_alert_published() +def crashlyticsvelocity( + alert: crashlytics_fn.CrashlyticsVelocityAlertEvent) -> None: + print(alert) + + +@performance_fn.on_threshold_alert_published() +def performancethreshold( + alert: performance_fn.PerformanceThresholdAlertEvent) -> None: + print(alert) diff --git a/samples/basic_alerts/functions/requirements.txt b/samples/basic_alerts/functions/requirements.txt new file mode 100644 index 0000000..8977a41 --- /dev/null +++ b/samples/basic_alerts/functions/requirements.txt @@ -0,0 +1,8 @@ +# Not published yet, +# firebase-functions-python >= 0.0.1 +# so we use a relative path during development: +./../../../ +# Or switch to git ref for deployment testing: +# git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions + +firebase-admin >= 6.0.1 diff --git a/src/firebase_functions/alerts/__init__.py b/src/firebase_functions/alerts/__init__.py new file mode 100644 index 0000000..52238e5 --- /dev/null +++ b/src/firebase_functions/alerts/__init__.py @@ -0,0 +1,46 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +""" +Cloud functions to handle events from Firebase Alerts. +Subpackages give stronger typing to specific services which +notify users via Firebase Alerts. +""" + +import dataclasses as _dataclasses +import datetime as _dt +import typing as _typing + +from firebase_functions.core import T + + +@_dataclasses.dataclass(frozen=True) +class FirebaseAlertData(_typing.Generic[T]): + """ + The CloudEvent data emitted by Firebase Alerts. + """ + + create_time: _dt.datetime + """ + The time the alert was created. + """ + + end_time: _dt.datetime | None + """ + The time the alert ended. This is only set for alerts that have ended. + """ + + payload: T + """ + Payload of the event, which includes the details of the specific alert. + """ diff --git a/src/firebase_functions/alerts/app_distribution_fn.py b/src/firebase_functions/alerts/app_distribution_fn.py new file mode 100644 index 0000000..5ff3a39 --- /dev/null +++ b/src/firebase_functions/alerts/app_distribution_fn.py @@ -0,0 +1,237 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +# pylint: disable=protected-access +""" +Cloud functions to handle Firebase App Distribution events from Firebase Alerts. +""" +import dataclasses as _dataclasses +import functools as _functools +import typing as _typing +import cloudevents.http as _ce +from firebase_functions.alerts import FirebaseAlertData + +import firebase_functions.private.util as _util + +from firebase_functions.core import T, CloudEvent +from firebase_functions.options import AppDistributionOptions + + +@_dataclasses.dataclass(frozen=True) +class NewTesterDevicePayload: + """ + The internal payload object for adding a new tester device to app distribution. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + tester_name: str + """ + Name of the tester. + """ + + tester_email: str + """ + Email of the tester. + """ + + tester_device_model_name: str + """ + The device model name. + """ + + tester_device_identifier: str + """ + The device ID. + """ + + +@_dataclasses.dataclass(frozen=True) +class InAppFeedbackPayload: + """ + The internal payload object for receiving in-app feedback from a tester. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + feedback_report: str + """ + Resource name. Format: + `projects/{project_number}/apps/{app_id}/releases/{release_id}/feedbackReports/{feedback_id}` + """ + + feedback_console_uri: str + """ + Deep link back to the Firebase console. + """ + + tester_email: str + """ + Email of the tester. + """ + + app_version: str + """ + Version consisting of `versionName` and `versionCode` for Android and + `CFBundleShortVersionString` and `CFBundleVersion` for iOS. + """ + + text: str + """ + Text entered by the tester. + """ + + tester_name: str | None = None + """ + Name of the tester. + """ + + screenshot_uri: str | None = None + """ + URI to download screenshot. This URI is fast expiring. + """ + + +@_dataclasses.dataclass(frozen=True) +class AppDistributionEvent(CloudEvent[FirebaseAlertData[T]]): + """ + A custom CloudEvent for billing Firebase Alerts. + """ + + alert_type: str + """ + The type of the alerts that got triggered. + """ + + app_id: str + """ + The Firebase App ID that's associated with the alert. + """ + + +NewTesterDeviceEvent = AppDistributionEvent[NewTesterDevicePayload] +""" +The type of the event for 'on_new_tester_ios_device_published' functions. +""" + +InAppFeedbackEvent = AppDistributionEvent[InAppFeedbackPayload] +""" +The type of the event for 'on_in_app_feedback_published' functions. +""" + +OnNewTesterIosDevicePublishedCallable = _typing.Callable[[NewTesterDeviceEvent], + None] +""" +The type of the callable for 'on_new_tester_ios_device_published' functions. +""" + +OnInAppFeedbackPublishedCallable = _typing.Callable[[InAppFeedbackEvent], None] +""" +The type of the callable for 'on_in_app_feedback_published' functions. +""" + + +@_util.copy_func_kwargs(AppDistributionOptions) +def on_new_tester_ios_device_published( + **kwargs +) -> _typing.Callable[[OnNewTesterIosDevicePublishedCallable], + OnNewTesterIosDevicePublishedCallable]: + """ + Event handler which runs every time a new tester iOS device is added. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.app_distribution_fn as app_distribution_fn + + @app_distribution_fn.on_new_tester_ios_device_published() + def example(alert: app_distribution_fn.NewTesterDeviceEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.AppDistributionOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.app_distribution_fn.NewTesterDeviceEvent` \\], + `None` + \\] + A function that takes a NewTesterDeviceEvent and returns None. + """ + options = AppDistributionOptions(**kwargs) + + def on_new_tester_ios_device_published_inner_decorator( + func: OnNewTesterIosDevicePublishedCallable): + + @_functools.wraps(func) + def on_new_tester_ios_device_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import app_distribution_event_from_ce + func(app_distribution_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_new_tester_ios_device_published_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type='appDistribution.newTesterIosDevice', + ), + ) + return on_new_tester_ios_device_published_wrapped + + return on_new_tester_ios_device_published_inner_decorator + + +@_util.copy_func_kwargs(AppDistributionOptions) +def on_in_app_feedback_published( + **kwargs +) -> _typing.Callable[[OnInAppFeedbackPublishedCallable], + OnInAppFeedbackPublishedCallable]: + """ + Event handler which runs every time new feedback is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.app_distribution_fn as app_distribution_fn + + @app_distribution_fn.on_in_app_feedback_published() + def example(alert: app_distribution_fn.InAppFeedbackEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.AppDistributionOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.app_distribution_fn.InAppFeedbackEvent` \\], + `None` + \\] + A function that takes a NewTesterDeviceEvent and returns None. + """ + options = AppDistributionOptions(**kwargs) + + def on_in_app_feedback_published_inner_decorator( + func: OnInAppFeedbackPublishedCallable): + + @_functools.wraps(func) + def on_in_app_feedback_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import app_distribution_event_from_ce + func(app_distribution_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_in_app_feedback_published_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type='appDistribution.inAppFeedback', + ), + ) + return on_in_app_feedback_published_wrapped + + return on_in_app_feedback_published_inner_decorator diff --git a/src/firebase_functions/alerts/billing_fn.py b/src/firebase_functions/alerts/billing_fn.py new file mode 100644 index 0000000..6c698ed --- /dev/null +++ b/src/firebase_functions/alerts/billing_fn.py @@ -0,0 +1,190 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +# pylint: disable=protected-access +""" +Cloud functions to handle billing events from Firebase Alerts. +""" +import dataclasses as _dataclasses +import functools as _functools +import typing as _typing +import cloudevents.http as _ce +from firebase_functions.alerts import FirebaseAlertData + +import firebase_functions.private.util as _util + +from firebase_functions.core import T, CloudEvent +from firebase_functions.options import BillingOptions + + +@_dataclasses.dataclass(frozen=True) +class PlanAutomatedUpdatePayload: + """ + The internal payload object for billing plan automated updates. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + billing_plan: str + """ + A Firebase billing plan, e.g. "spark" or "blaze". + """ + + notification_type: str + """ + The type of the notification, e.g. "upgrade_plan" or "downgrade_plan". + """ + + +@_dataclasses.dataclass(frozen=True) +class PlanUpdatePayload(PlanAutomatedUpdatePayload): + """ + The internal payload object for billing plan updates. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + principal_email: str + """ + The email address of the person that triggered billing plan change. + """ + + +@_dataclasses.dataclass(frozen=True) +class BillingEvent(CloudEvent[FirebaseAlertData[T]]): + """ + A custom CloudEvent for billing Firebase Alerts. + """ + + alert_type: str + """ + The type of the alerts that got triggered. + """ + + +BillingPlanUpdateEvent = BillingEvent[PlanUpdatePayload] +""" +The type of the event for 'on_plan_update_published' functions. +""" + +BillingPlanAutomatedUpdateEvent = BillingEvent[PlanAutomatedUpdatePayload] +""" +The type of the event for 'on_plan_automated_update_published' functions. +""" + +OnPlanUpdatePublishedCallable = _typing.Callable[[BillingPlanUpdateEvent], None] +""" +The type of the callable for 'on_plan_update_published' functions. +""" + +OnPlanAutomatedUpdatePublishedCallable = _typing.Callable[ + [BillingPlanAutomatedUpdateEvent], None] +""" +The type of the callable for 'on_plan_automated_update_published' functions. +""" + + +@_util.copy_func_kwargs(BillingOptions) +def on_plan_update_published( + **kwargs +) -> _typing.Callable[[OnPlanUpdatePublishedCallable], + OnPlanUpdatePublishedCallable]: + """ + Event handler which triggers when a Firebase Alerts billing event is published. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.billing_fn as billing_fn + + @billing_fn.on_plan_update_published() + def example(alert: billing_fn.BillingPlanUpdateEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BillingOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.billing_fn.BillingPlanUpdateEvent` \\], + `None` + \\] + A function that takes a BillingPlanUpdateEvent and returns None. + """ + options = BillingOptions(**kwargs) + + def on_plan_update_published_inner_decorator( + func: OnPlanUpdatePublishedCallable): + + @_functools.wraps(func) + def on_plan_update_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import billing_event_from_ce + func(billing_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_plan_update_published_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type='billing.planUpdate', + ), + ) + return on_plan_update_published_wrapped + + return on_plan_update_published_inner_decorator + + +@_util.copy_func_kwargs(BillingOptions) +def on_plan_automated_update_published( + **kwargs +) -> _typing.Callable[[OnPlanAutomatedUpdatePublishedCallable], + OnPlanAutomatedUpdatePublishedCallable]: + """ + Event handler which triggers when a Firebase Alerts billing event is published. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.billing_fn as billing_fn + + @billing_fn.on_plan_automated_update_published() + def example(alert: billing_fn.BillingPlanAutomatedUpdateEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BillingOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.billing_fn.BillingPlanAutomatedUpdateEvent` \\], + `None` + \\] + A function that takes a BillingPlanUpdateEvent and returns None. + """ + options = BillingOptions(**kwargs) + + def on_plan_automated_update_published_inner_decorator( + func: OnPlanAutomatedUpdatePublishedCallable): + + @_functools.wraps(func) + def on_plan_automated_update_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import billing_event_from_ce + func(billing_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_plan_automated_update_published_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type='billing.planAutomatedUpdate', + ), + ) + return on_plan_automated_update_published_wrapped + + return on_plan_automated_update_published_inner_decorator diff --git a/src/firebase_functions/alerts/crashlytics_fn.py b/src/firebase_functions/alerts/crashlytics_fn.py new file mode 100644 index 0000000..915c454 --- /dev/null +++ b/src/firebase_functions/alerts/crashlytics_fn.py @@ -0,0 +1,489 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +# pylint: disable=protected-access,line-too-long +""" +Cloud functions to handle Crashlytics events from Firebase Alerts. +""" +import dataclasses as _dataclasses +import typing as _typing +import cloudevents.http as _ce +import datetime as _dt +import functools as _functools +from firebase_functions.alerts import FirebaseAlertData +from firebase_functions.core import T, CloudEvent +from firebase_functions.options import CrashlyticsOptions +import firebase_functions.private.util as _util + + +@_dataclasses.dataclass(frozen=True) +class Issue: + """ + Generic Crashlytics issue interface + """ + + id: str + """ + The ID of the Crashlytics issue + """ + + title: str + """ + The title of the Crashlytics issue + """ + + subtitle: str + """ + The subtitle of the Crashlytics issue + """ + + app_version: str + """ + The application version of the Crashlytics issue + """ + + +@_dataclasses.dataclass(frozen=True) +class NewFatalIssuePayload: + """ + The internal payload object for a new fatal issue. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + +@_dataclasses.dataclass(frozen=True) +class NewNonfatalIssuePayload: + """ + The internal payload object for a new non-fatal issue. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + +@_dataclasses.dataclass(frozen=True) +class RegressionAlertPayload: + """ + The internal payload object for a regression alert. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + type: str + """ + The type of the Crashlytics issue, e.g. new fatal, new nonfatal, ANR + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + resolve_time: _dt.datetime + """ + The time that the Crashlytics issues was most recently resolved before it + began to reoccur. + """ + + +@_dataclasses.dataclass(frozen=True) +class TrendingIssueDetails: + """ + Generic Crashlytics trending issue interface + """ + + type: str + """ + The type of the Crashlytics issue, e.g. new fatal, new nonfatal, ANR + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + event_count: int + """ + The number of crashes that occurred with the issue + """ + + user_count: int + """ + The number of distinct users that were affected by the issue + """ + + +@_dataclasses.dataclass(frozen=True) +class StabilityDigestPayload: + """ + The internal payload object for a stability digest. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + digest_date: _dt.datetime + """ + The date that the digest gets created. Issues in the digest should have the + same date as the digest date + """ + + trending_issues: list[TrendingIssueDetails] + """ + A stability digest containing several trending Crashlytics issues + """ + + +@_dataclasses.dataclass(frozen=True) +class VelocityAlertPayload: + """ + The internal payload object for a velocity alert. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + create_time: _dt.datetime + """ + The time that the Crashlytics issue gets created + """ + + crash_count: int + """ + The number of user sessions for the given app version that had this + specific crash issue in the time period used to trigger the velocity alert. + """ + + crash_percentage: float + """ + The percentage of user sessions for the given app version that had this + specific crash issue in the time period used to trigger the velocity alert. + """ + + first_version: str + """ + The first app version where this issue was seen, and not necessarily the + version that has triggered the alert. + """ + + +@_dataclasses.dataclass(frozen=True) +class NewAnrIssuePayload: + """ + The internal payload object for a new Application Not Responding issue. + Payload is wrapped inside a `FirebaseAlertData` object. + """ + + issue: Issue + """ + Basic information of the Crashlytics issue + """ + + +@_dataclasses.dataclass(frozen=True) +class CrashlyticsEvent(CloudEvent[FirebaseAlertData[T]]): + """ + A custom CloudEvent for billing Firebase Alerts. + """ + + alert_type: str + """ + The type of the alerts that got triggered. + """ + + app_id: str + """ + The Firebase App ID that's associated with the alert. + """ + + +CrashlyticsNewFatalIssueEvent = CrashlyticsEvent[NewFatalIssuePayload] +""" +The type of the event for 'on_new_fatal_issue_published' functions. +""" + +OnNewFatalIssuePublishedCallable = _typing.Callable[ + [CrashlyticsNewFatalIssueEvent], None] +""" +The type of the callable for 'on_new_fatal_issue_published' functions. +""" + +CrashlyticsNewNonfatalIssueEvent = CrashlyticsEvent[NewNonfatalIssuePayload] +""" +The type of the event for 'on_new_nonfatal_issue_published' functions. +""" + +OnNewNonfatalIssuePublishedCallable = _typing.Callable[ + [CrashlyticsNewNonfatalIssueEvent], None] +""" +The type of the callable for 'on_new_nonfatal_issue_published' functions. +""" + +CrashlyticsRegressionAlertEvent = CrashlyticsEvent[RegressionAlertPayload] +""" +The type of the event for 'on_regression_alert_published' functions. +""" + +OnRegressionAlertPublishedCallable = _typing.Callable[ + [CrashlyticsRegressionAlertEvent], None] +""" +The type of the callable for 'on_regression_alert_published' functions. +""" + +CrashlyticsStabilityDigestEvent = CrashlyticsEvent[StabilityDigestPayload] +""" +The type of the event for 'on_stability_digest_published' functions. +""" + +OnStabilityDigestPublishedCallable = _typing.Callable[ + [CrashlyticsStabilityDigestEvent], None] +""" +The type of the callable for 'on_stability_digest_published' functions. +""" + +CrashlyticsVelocityAlertEvent = CrashlyticsEvent[VelocityAlertPayload] +""" +The type of the event for 'on_velocity_alert_published' functions. +""" + +OnVelocityAlertPublishedCallable = _typing.Callable[ + [CrashlyticsVelocityAlertEvent], None] +""" +The type of the callable for 'on_velocity_alert_published' functions. +""" + +CrashlyticsNewAnrIssueEvent = CrashlyticsEvent[NewAnrIssuePayload] +""" +The type of the event for 'on_new_anr_issue_published' functions. +""" + +OnNewAnrIssuePublishedCallable = _typing.Callable[[CrashlyticsNewAnrIssueEvent], + None] +""" +The type of the callable for 'on_new_anr_issue_published' functions. +""" + + +def _create_crashlytics_decorator( + alert_type: str, + **kwargs, +) -> _typing.Callable: + options = CrashlyticsOptions(**kwargs) + + def crashlytics_decorator_inner(func: _typing.Callable): + + @_functools.wraps(func) + def crashlytics_decorator_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import crashlytics_event_from_ce + func(crashlytics_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + crashlytics_decorator_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type=alert_type, + ), + ) + return crashlytics_decorator_wrapped + + return crashlytics_decorator_inner + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_new_fatal_issue_published( + **kwargs +) -> _typing.Callable[[OnNewFatalIssuePublishedCallable], + OnNewFatalIssuePublishedCallable]: + """ + Event handler which runs every time a new fatal issue is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_new_fatal_issue_published() + def example(alert: crashlytics_fn.CrashlyticsNewFatalIssueEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsNewFatalIssueEvent` \\], + `None` + \\] + A function that takes a CrashlyticsNewFatalIssueEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.newFatalIssue', **kwargs) + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_new_nonfatal_issue_published( + **kwargs +) -> _typing.Callable[[OnNewNonfatalIssuePublishedCallable], + OnNewNonfatalIssuePublishedCallable]: + """ + Event handler which runs every time a new nonfatal issue is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_new_nonfatal_issue_published() + def example(alert: crashlytics_fn.CrashlyticsNewNonfatalIssueEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsNewNonfatalIssueEvent` \\], + `None` + \\] + A function that takes a CrashlyticsNewNonfatalIssueEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.newNonfatalIssue', + **kwargs) + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_regression_alert_published( + **kwargs +) -> _typing.Callable[[OnRegressionAlertPublishedCallable], + OnRegressionAlertPublishedCallable]: + """ + Event handler which runs every time a regression alert is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_regression_alert_published() + def example(alert: crashlytics_fn.CrashlyticsRegressionAlertEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsRegressionAlertEvent` \\], + `None` + \\] + A function that takes a CrashlyticsRegressionAlertEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.regression', **kwargs) + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_stability_digest_published( + **kwargs +) -> _typing.Callable[[OnStabilityDigestPublishedCallable], + OnStabilityDigestPublishedCallable]: + """ + Event handler which runs every time a stability digest is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_stability_digest_published() + def example(alert: crashlytics_fn.CrashlyticsStabilityDigestEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsStabilityDigestEvent` \\], + `None` + \\] + A function that takes a CrashlyticsStabilityDigestEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.stabilityDigest', + **kwargs) + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_velocity_alert_published( + **kwargs +) -> _typing.Callable[[OnVelocityAlertPublishedCallable], + OnVelocityAlertPublishedCallable]: + """ + Event handler which runs every time a velocity alert is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_velocity_alert_published() + def example(alert: crashlytics_fn.CrashlyticsVelocityAlertEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsVelocityAlertEvent` \\], + `None` + \\] + A function that takes a CrashlyticsVelocityAlertEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.velocity', **kwargs) + + +@_util.copy_func_kwargs(CrashlyticsOptions) +def on_new_anr_issue_published( + **kwargs +) -> _typing.Callable[[OnNewAnrIssuePublishedCallable], + OnNewAnrIssuePublishedCallable]: + """ + Event handler which runs every time a new ANR issue is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.crashlytics_fn as crashlytics_fn + + @crashlytics_fn.on_new_anr_issue_published() + def example(alert: crashlytics_fn.CrashlyticsNewAnrIssueEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.CrashlyticsOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.crashlytics_fn.CrashlyticsNewAnrIssueEvent` \\], + `None` + \\] + A function that takes a CrashlyticsNewAnrIssueEvent and returns None. + """ + return _create_crashlytics_decorator('crashlytics.newAnrIssue', **kwargs) diff --git a/src/firebase_functions/alerts/performance_fn.py b/src/firebase_functions/alerts/performance_fn.py new file mode 100644 index 0000000..ad00c6a --- /dev/null +++ b/src/firebase_functions/alerts/performance_fn.py @@ -0,0 +1,177 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +# pylint: disable=protected-access +""" +Cloud functions to handle Firebase Performance Monitoring events from Firebase Alerts. +""" + +import dataclasses as _dataclasses +import functools as _functools +import typing as _typing +import cloudevents.http as _ce +from firebase_functions.alerts import FirebaseAlertData + +import firebase_functions.private.util as _util + +from firebase_functions.core import T, CloudEvent +from firebase_functions.options import PerformanceOptions + + +@_dataclasses.dataclass(frozen=True) +class ThresholdAlertPayload: + """ + The internal payload object for a performance threshold alert. + Payload is wrapped inside a FirebaseAlertData object. + """ + + event_name: str + """ + Name of the trace or network request this alert is for + (e.g. my_custom_trace, firebase.com/api/123). + """ + + event_type: str + """ + The resource type this alert is for (i.e. trace, network request, + screen rendering, etc.). + """ + + metric_type: str + """ + The metric type this alert is for (i.e. success rate, + response time, duration, etc.). + """ + + num_samples: int + """ + The number of events checked for this alert condition. + """ + + threshold_value: float + """ + The threshold value of the alert condition without units (e.g. "75", "2.1"). + """ + + threshold_unit: str + """ + The unit for the alert threshold (e.g. "percent", "seconds"). + """ + + violation_value: float | int + """ + The value that violated the alert condition (e.g. "76.5", "3"). + """ + + violation_unit: str + """ + The unit for the violation value (e.g. "percent", "seconds"). + """ + + investigate_uri: str + """ + The link to Firebase Console to investigate more into this alert. + """ + + condition_percentile: float | int | None = None + """ + The percentile of the alert condition, can be 0 if percentile + is not applicable to the alert condition and omitted; + range: [1, 100]. + """ + + app_version: str | None = None + """ + The app version this alert was triggered for, can be omitted + if the alert is for a network request (because the alert was + checked against data from all versions of app) or a web app + (where the app is versionless). + """ + + +@_dataclasses.dataclass(frozen=True) +class PerformanceEvent(CloudEvent[FirebaseAlertData[T]]): + """ + A custom CloudEvent for billing Firebase Alerts. + """ + + alert_type: str + """ + The type of the alerts that got triggered. + """ + + app_id: str + """ + The Firebase App ID that's associated with the alert. + """ + + +PerformanceThresholdAlertEvent = PerformanceEvent[ThresholdAlertPayload] +""" +The type of the event for 'on_threshold_alert_published' functions. +""" + +OnThresholdAlertPublishedCallable = _typing.Callable[ + [PerformanceThresholdAlertEvent], None] +""" +The type of the callable for 'on_threshold_alert_published' functions. +""" + + +@_util.copy_func_kwargs(PerformanceOptions) +def on_threshold_alert_published( + **kwargs +) -> _typing.Callable[[OnThresholdAlertPublishedCallable], + OnThresholdAlertPublishedCallable]: + """ + Event handler which runs every time a threshold alert is received. + + Example: + + .. code-block:: python + + import firebase_functions.alerts.performance_fn as performance_fn + + @performance_fn.on_threshold_alert_published() + def example(alert: performance_fn.PerformanceThresholdAlertEvent) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.PerformanceOptions` + :rtype: :exc:`typing.Callable` + \\[ + \\[ :exc:`firebase_functions.alerts.performance_fn.PerformanceThresholdAlertEvent` \\], + `None` + \\] + A function that takes a PerformanceThresholdAlertEvent and returns None. + """ + options = PerformanceOptions(**kwargs) + + def on_threshold_alert_published_inner_decorator( + func: OnThresholdAlertPublishedCallable): + + @_functools.wraps(func) + def on_threshold_alert_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import performance_event_from_ce + func(performance_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_threshold_alert_published_wrapped, + options._endpoint( + func_name=func.__name__, + alert_type='performance.threshold', + ), + ) + return on_threshold_alert_published_wrapped + + return on_threshold_alert_published_inner_decorator diff --git a/src/firebase_functions/alerts_fn.py b/src/firebase_functions/alerts_fn.py new file mode 100644 index 0000000..ae3ac7c --- /dev/null +++ b/src/firebase_functions/alerts_fn.py @@ -0,0 +1,106 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +# pylint: disable=protected-access +""" +Cloud functions to handle events from Firebase Alerts. +""" + +import dataclasses as _dataclasses +import functools as _functools +import typing as _typing +import cloudevents.http as _ce +from firebase_functions.alerts import FirebaseAlertData + +import firebase_functions.private.util as _util + +from firebase_functions.core import T, CloudEvent as _CloudEvent +from firebase_functions.options import FirebaseAlertOptions + +# Explicitly import AlertType to make it available in the public API. +# pylint: disable=unused-import +from firebase_functions.options import AlertType + + +@_dataclasses.dataclass(frozen=True) +class AlertEvent(_CloudEvent[T]): + """ + A custom CloudEvent for Firebase Alerts (with custom extension attributes). + """ + + alert_type: str + """ + The type of the alerts that got triggered. + """ + + app_id: str | None + """ + The Firebase App ID that's associated with the alert. This is optional, + and only present when the alert is targeting at a specific Firebase App. + """ + + +OnAlertPublishedEvent = AlertEvent[FirebaseAlertData[T]] +""" +The type of the event for 'on_alert_published' functions. +""" + +OnAlertPublishedCallable = _typing.Callable[[OnAlertPublishedEvent], None] +""" +The type of the callable for 'on_alert_published' functions. +""" + + +@_util.copy_func_kwargs(FirebaseAlertOptions) +def on_alert_published( + **kwargs +) -> _typing.Callable[[OnAlertPublishedCallable], OnAlertPublishedCallable]: + """ + Event handler which triggers when a Firebase Alerts event is published. + + Example: + + .. code-block:: python + + from firebase_functions import alerts_fn + + @alerts_fn.on_alert_published( + alert_type=alerts_fn.AlertType.CRASHLYTICS_NEW_FATAL_ISSUE, + ) + def example(alert: alerts_fn.AlertEvent[alerts_fn.FirebaseAlertData]) -> None: + print(alert) + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.FirebaseAlertOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.alerts_fn.AlertEvent` \\[ + :exc:`firebase_functions.alerts_fn.FirebaseAlertData` \\[ + :exc:`typing.Any` \\] \\] \\], `None` \\] + A function that takes a AlertEvent and returns None. + """ + options = FirebaseAlertOptions(**kwargs) + + def on_alert_published_inner_decorator(func: OnAlertPublishedCallable): + + @_functools.wraps(func) + def on_alert_published_wrapped(raw: _ce.CloudEvent): + from firebase_functions.private._alerts_fn import alerts_event_from_ce + func(alerts_event_from_ce(raw)) + + _util.set_func_endpoint_attr( + on_alert_published_wrapped, + options._endpoint(func_name=func.__name__), + ) + return on_alert_published_wrapped + + return on_alert_published_inner_decorator diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index bcdd8e7..d99be1f 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -502,6 +502,191 @@ def _endpoint( **kwargs, event_filters=event_filters, event_type=event_type)))) +class AlertType(str, _enum.Enum): + """ + The underlying alert type of the Firebase Alerts provider. + """ + + CRASHLYTICS_NEW_FATAL_ISSUE = "crashlytics.newFatalIssue" + """ + Crashlytics new fatal issue alerts. + """ + + CRASHLYTICS_NEW_NONFATAL_ISSUE = "crashlytics.newNonfatalIssue" + """ + Crashlytics new non-fatal issue alerts. + """ + + CRASHLYTICS_REGRESSION = "crashlytics.regression" + """ + Crashlytics regression alerts. + """ + + CRASHLYTICS_STABILITY_DIGEST = "crashlytics.stabilityDigest" + """ + Crashlytics stability digest alerts. + """ + + CRASHLYTICS_VELOCITY = "crashlytics.velocity" + """ + Crashlytics velocity alerts. + """ + + CRASHLYTICS_NEW_ANR_ISSUE = "crashlytics.newAnrIssue" + """ + Crashlytics new ANR issue alerts. + """ + + BILLING_PLAN_UPDATE = "billing.planUpdate" + """ + Billing plan update alerts. + """ + + BILLING_PLAN_AUTOMATED_UPDATE = "billing.planAutomatedUpdate" + """ + Billing automated plan update alerts. + """ + + APP_DISTRIBUTION_NEW_TESTER_IOS_DEVICE = "appDistribution.newTesterIosDevice" + """ + App Distribution new tester iOS device alerts. + """ + + APP_DISTRIBUTION_IN_APP_FEEDBACK = "appDistribution.inAppFeedback" + """ + App Distribution in-app feedback alerts. + """ + + PERFORMANCE_THRESHOLD = "performance.threshold" + """ + Performance threshold alerts. + """ + + +@_dataclasses.dataclass(frozen=True, kw_only=True) +class FirebaseAlertOptions(EventHandlerOptions): + """ + Options specific to Firebase Alert function types. + Internal use only. + """ + + alert_type: str | AlertType + """ + The Firebase Alert type to listen to. Can be an AlertType enum + or string. + """ + + app_id: str | None = None + """ + An optional app ID to scope down alerts. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + event_filters: _typing.Any = { + "alerttype": self.alert_type, + } + + if self.app_id is not None: + event_filters["appid"] = self.app_id + + event_type = "google.firebase.firebasealerts.alerts.v1.published" + return _manifest.ManifestEndpoint(**_typing.cast( + _typing.Dict, + _dataclasses.asdict(super()._endpoint( + **kwargs, + event_filters=event_filters, + event_type=event_type, + )))) + + +@_dataclasses.dataclass(frozen=True, kw_only=True) +class AppDistributionOptions(EventHandlerOptions): + """ + Options specific to app distribution functions. + Internal use only. + """ + + app_id: str | None = None + """ + An optional app ID to scope down alerts. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + assert kwargs["alert_type"] is not None + return FirebaseAlertOptions( + alert_type=kwargs["alert_type"], + app_id=self.app_id, + )._endpoint(**kwargs) + + +@_dataclasses.dataclass(frozen=True, kw_only=True) +class PerformanceOptions(EventHandlerOptions): + """ + Options specific to performance alerts functions. + Internal use only. + """ + + app_id: str | None = None + """ + An optional app ID to scope down alerts. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + assert kwargs["alert_type"] is not None + return FirebaseAlertOptions( + alert_type=kwargs["alert_type"], + app_id=self.app_id, + )._endpoint(**kwargs) + + +@_dataclasses.dataclass(frozen=True, kw_only=True) +class CrashlyticsOptions(EventHandlerOptions): + """ + Options specific to Crashlytics alert functions. + Internal use only. + """ + + app_id: str | None = None + """ + An optional app ID to scope down alerts. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + assert kwargs["alert_type"] is not None + return FirebaseAlertOptions( + alert_type=kwargs["alert_type"], + app_id=self.app_id, + )._endpoint(**kwargs) + + +@_dataclasses.dataclass(frozen=True, kw_only=True) +class BillingOptions(EventHandlerOptions): + """ + Options specific to billing alert functions. + Internal use only. + """ + + def _endpoint( + self, + **kwargs, + ) -> _manifest.ManifestEndpoint: + assert kwargs["alert_type"] is not None + return FirebaseAlertOptions( + alert_type=kwargs["alert_type"],)._endpoint(**kwargs) + + @_dataclasses.dataclass(frozen=True, kw_only=True) class EventarcTriggerOptions(EventHandlerOptions): """ diff --git a/src/firebase_functions/private/_alerts_fn.py b/src/firebase_functions/private/_alerts_fn.py new file mode 100644 index 0000000..53c3ff7 --- /dev/null +++ b/src/firebase_functions/private/_alerts_fn.py @@ -0,0 +1,254 @@ +# Copyright 2022 Google Inc. +# +# 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 +# +# http://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. +"""Internal utilities for Firebase Alert function types.""" + +# pylint: disable=protected-access +import typing as _typing +import datetime as _dt +import cloudevents.http as _ce +from firebase_functions.alerts import FirebaseAlertData + +from functions_framework import logging as _logging + + +def plan_update_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.billing_fn import PlanUpdatePayload + return PlanUpdatePayload( + notification_type=payload["notificationType"], + billing_plan=payload["billingPlan"], + principal_email=payload["principalEmail"], + ) + + +def plan_automated_update_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.billing_fn import PlanAutomatedUpdatePayload + return PlanAutomatedUpdatePayload( + notification_type=payload["notificationType"], + billing_plan=payload["billingPlan"], + ) + + +def in_app_feedback_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.app_distribution_fn import InAppFeedbackPayload + return InAppFeedbackPayload( + feedback_report=payload["feedbackReport"], + feedback_console_uri=payload["feedbackConsoleUri"], + tester_name=payload.get("testerName"), + tester_email=payload["testerEmail"], + app_version=payload["appVersion"], + text=payload["text"], + screenshot_uri=payload.get("screenshotUri"), + ) + + +def new_tester_device_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.app_distribution_fn import NewTesterDevicePayload + return NewTesterDevicePayload( + tester_name=payload["testerName"], + tester_email=payload["testerEmail"], + tester_device_model_name=payload["testerDeviceModelName"], + tester_device_identifier=payload["testerDeviceIdentifier"], + ) + + +def threshold_alert_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.performance_fn import ThresholdAlertPayload + return ThresholdAlertPayload( + event_name=payload["eventName"], + event_type=payload["eventType"], + metric_type=payload["metricType"], + num_samples=payload["numSamples"], + threshold_value=payload["thresholdValue"], + threshold_unit=payload["thresholdUnit"], + condition_percentile=payload.get("conditionPercentile"), + app_version=payload.get("appVersion"), + violation_value=payload["violationValue"], + violation_unit=payload["violationUnit"], + investigate_uri=payload["investigateUri"], + ) + + +def issue_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import Issue + return Issue( + id=payload["id"], + title=payload["title"], + subtitle=payload["subtitle"], + app_version=payload["appVersion"], + ) + + +def new_fatal_issue_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import NewFatalIssuePayload + return NewFatalIssuePayload(issue=issue_from_ce_payload(payload["issue"])) + + +def new_nonfatal_issue_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import NewNonfatalIssuePayload + return NewNonfatalIssuePayload( + issue=issue_from_ce_payload(payload["issue"])) + + +def regression_alert_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import RegressionAlertPayload + return RegressionAlertPayload( + type=payload["type"], + issue=issue_from_ce_payload(payload["issue"]), + resolve_time=_dt.datetime.strptime( + payload["resolveTime"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + ) + + +def trending_issue_details_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import TrendingIssueDetails + return TrendingIssueDetails( + type=payload["type"], + issue=issue_from_ce_payload(payload["issue"]), + event_count=payload["eventCount"], + user_count=payload["userCount"], + ) + + +def stability_digest_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import StabilityDigestPayload + return StabilityDigestPayload( + digest_date=_dt.datetime.strptime( + payload["digestDate"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + trending_issues=[ + trending_issue_details_from_ce_payload(issue) + for issue in payload["trendingIssues"] + ]) + + +def velocity_alert_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import VelocityAlertPayload + return VelocityAlertPayload( + issue=issue_from_ce_payload(payload["issue"]), + create_time=_dt.datetime.strptime( + payload["createTime"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + crash_count=payload["crashCount"], + crash_percentage=payload["crashPercentage"], + first_version=payload["firstVersion"], + ) + + +def new_anr_issue_payload_from_ce_payload(payload: dict): + from firebase_functions.alerts.crashlytics_fn import NewAnrIssuePayload + return NewAnrIssuePayload(issue=issue_from_ce_payload(payload["issue"])) + + +def firebase_alert_data_from_ce(event_dict: dict,) -> FirebaseAlertData: + from firebase_functions.options import AlertType + alert_type: str = event_dict["alerttype"] + alert_payload = event_dict["payload"] + if alert_type == AlertType.CRASHLYTICS_NEW_FATAL_ISSUE.value: + alert_payload = new_fatal_issue_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.CRASHLYTICS_NEW_NONFATAL_ISSUE.value: + alert_payload = new_nonfatal_issue_payload_from_ce_payload( + alert_payload) + elif alert_type == AlertType.CRASHLYTICS_REGRESSION.value: + alert_payload = regression_alert_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.CRASHLYTICS_STABILITY_DIGEST.value: + alert_payload = stability_digest_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.CRASHLYTICS_VELOCITY.value: + alert_payload = velocity_alert_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.CRASHLYTICS_NEW_ANR_ISSUE.value: + alert_payload = new_anr_issue_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.BILLING_PLAN_UPDATE.value: + alert_payload = plan_update_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.BILLING_PLAN_AUTOMATED_UPDATE.value: + alert_payload = plan_automated_update_payload_from_ce_payload( + alert_payload) + elif alert_type == AlertType.APP_DISTRIBUTION_NEW_TESTER_IOS_DEVICE.value: + alert_payload = new_tester_device_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.APP_DISTRIBUTION_IN_APP_FEEDBACK.value: + alert_payload = in_app_feedback_payload_from_ce_payload(alert_payload) + elif alert_type == AlertType.PERFORMANCE_THRESHOLD.value: + alert_payload = threshold_alert_payload_from_ce_payload(alert_payload) + else: + _logging.warning(f"Unhandled Firebase Alerts alert type: {alert_type}") + + return FirebaseAlertData( + create_time=_dt.datetime.strptime( + event_dict["createTime"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + end_time=_dt.datetime.strptime( + event_dict["endTime"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ) if "endTime" in event_dict else None, + payload=alert_payload, + ) + + +def event_from_ce_helper(raw: _ce.CloudEvent, cls, app_id=True): + event_attributes = raw._get_attributes() + event_data: _typing.Any = raw.get_data() + event_dict = {**event_data, **event_attributes} + alert_type: str = event_dict["alerttype"] + event_kwargs = { + "alert_type": + alert_type, + "data": + firebase_alert_data_from_ce(event_dict), + "id": + event_dict["id"], + "source": + event_dict["source"], + "specversion": + event_dict["specversion"], + "subject": + event_dict["subject"] if "subject" in event_dict else None, + "time": + _dt.datetime.strptime( + event_dict["time"], + "%Y-%m-%dT%H:%M:%S.%f%z", + ), + "type": + event_dict["type"], + } + if app_id: + event_kwargs["app_id"] = event_dict.get("appid") + return cls(**event_kwargs) + + +def billing_event_from_ce(raw: _ce.CloudEvent): + from firebase_functions.alerts.billing_fn import BillingEvent + return event_from_ce_helper(raw, BillingEvent, app_id=False) + + +def performance_event_from_ce(raw: _ce.CloudEvent): + from firebase_functions.alerts.performance_fn import PerformanceEvent + return event_from_ce_helper(raw, PerformanceEvent) + + +def app_distribution_event_from_ce(raw: _ce.CloudEvent): + from firebase_functions.alerts.app_distribution_fn import AppDistributionEvent + return event_from_ce_helper(raw, AppDistributionEvent) + + +def crashlytics_event_from_ce(raw: _ce.CloudEvent): + from firebase_functions.alerts.crashlytics_fn import CrashlyticsEvent + return event_from_ce_helper(raw, CrashlyticsEvent) + + +def alerts_event_from_ce(raw: _ce.CloudEvent): + from firebase_functions.alerts_fn import AlertEvent + return event_from_ce_helper(raw, AlertEvent)