diff --git a/.github/workflows/upload-extension.yml b/.github/workflows/upload-extension.yml index 1e33f3c..34621c1 100644 --- a/.github/workflows/upload-extension.yml +++ b/.github/workflows/upload-extension.yml @@ -31,8 +31,6 @@ jobs: echo "GOOGLE_APPLICATION_CREDENTIALS=$(pwd)/service_account.json" >> $GITHUB_ENV echo ${{ secrets.FIREBASE_SA }} | base64 -d -i - > service_account.json - - run: firebase login:list - - run: yarn --cwd functions install - run: yarn --cwd functions build diff --git a/CHANGELOG.md b/CHANGELOG.md index e09baeb..842ea82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,24 @@ +## 0.0.14 + +- Adding integration with Google Gemini API + ## 0.0.13 + - Updating node version to 18 - Moving webhooks into secrets instead of Firestore configuration. ## 0.0.7 + Fixing function trigger type ## 0.0.1 + Initial release. -Implementing support for webhook integration with Google Chat to send a +Implementing support for webhook integration with Google Chat to send a card with Github and Crashlytics quick actions. - - Refactoring Github repo information in App Info - - Adding crashlytics support for Slack and Discord - - Adding functions to capture billing, app distribution and performance alerts - with no handling apart from debug logging. Needed for collecting sample data. - \ No newline at end of file +- Refactoring Github repo information in App Info +- Adding crashlytics support for Slack and Discord +- Adding functions to capture billing, app distribution and performance alerts + with no handling apart from debug logging. Needed for collecting sample data. diff --git a/POSTINSTALL.md b/POSTINSTALL.md index 4929862..91e9cb3 100644 --- a/POSTINSTALL.md +++ b/POSTINSTALL.md @@ -1,13 +1,3 @@ -## Configuring your webhooks -Read the official documentation for each of the platforms on how to configure -webhooks. - -* [Google Chat](https://developers.google.com/hangouts/chat/how-tos/webhooks) -* [Slack](https://slack.com/help/articles/115005265063-Incoming-webhooks-for-Slack) -* [Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) - -There is a square Firebase icon under the [`/icons/`](https://github.com/oddbit/firebase-alerts/raw/main/icons) -folder that you can use for your webhook avatar. Use this permalink to the image: [https://github.com/oddbit/firebase-alerts/raw/main/icons/firebase.png](https://github.com/oddbit/firebase-alerts/raw/main/icons/firebase.png) ## Configuring apps diff --git a/PREINSTALL.md b/PREINSTALL.md index 7bc3724..27c8ea1 100644 --- a/PREINSTALL.md +++ b/PREINSTALL.md @@ -1,58 +1,57 @@ -Use this extension to configure multiple webhooks to social platforms where you -want to receive Firebase Alerts notifications. See the official documentation as -an example use-case: https://firebase.google.com/docs/functions/beta/alert-events#trigger-function-on-alert-events +Use this extension to set up a webhook for social platforms where you want to receive Firebase Alerts notifications. For an example use case, refer to the official documentation: [Firebase Alerts Documentation](https://firebase.google.com/docs/functions/beta/alert-events#trigger-function-on-alert-events). -The social platform notification messages are offering quick actions to jump -straight into the Firebase console for detailed information and optionally to -create github issues if applicable. +This extension enables quick actions through social platform notifications, allowing direct access to the Firebase console for detailed information. Optionally, it supports creating GitHub issues if GitHub repository information is configured. -This extension adds a highly configurable way of registering multiple webhooks to -be triggered for each event. The plugin also supports multiple platforms - - - Google Chat - - Slack - - Discord - -See [README](https://github.com/oddbit/firebase-alerts#readme) for complete list -of feature and platform support +The extension offers a webhook that are triggered for each event. It also supports multiple platforms. For a complete list of features and supported platforms, see the [README](https://github.com/oddbit/firebase-alerts#readme). # Configuring the extension + ## Webhooks + +To install the extension, you must define a webhook for a social platform. The extension require at least one webhook to be defined during the installation. At the moment you can only declare one webhook per platform. This webhook URL can be obtained by reading the apps and integrations documentation -for any of the platforms that are supported by this extension: -[Google Chat](https://developers.google.com/chat/how-tos/webhooks#create_a_webhook), -[Slack](https://slack.com/apps/A0F7XDUAZ-incoming-webhooks), -and [Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). +for any of the platforms that are supported by this extension: + +- [Google Chat](https://developers.google.com/hangouts/chat/how-tos/webhooks) +- [Slack](https://slack.com/help/articles/115005265063-Incoming-webhooks-for-Slack) +- [Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) + +For your webhook avatar, use the square Firebase icon located in the [`/icons/`](https://github.com/oddbit/firebase-alerts/raw/main/icons) folder. Here's a permalink to the image: [Firebase Icon](https://github.com/oddbit/firebase-alerts/raw/main/icons/firebase.png) ## App information + You will be required to explicitly configure app id, bundle in order for the extension to be able to generate URLs to Firebase console, to make direct links to crashlytics etc. ### App ID -The app ID is the string that is uniquely used by Firebase to identify your application and + +The app ID is the string that is uniquely used by Firebase to identify your application and you can find it in the Firebase console looking something like this: `1:269808624035:android:296863cf1f5b6817c87a16` ### Bundle ID -The bundle id is the ID that you have configured in your mobile app configuration, e.g. `id.oddbit.app.example`. +The bundle id is the ID that you have configured in your mobile app configuration, e.g. `id.oddbit.app.example`. Although web apps do not have bundle ids, Firebase is still using an equivalent representation for some of the console URLs. As shown in the example below, -you can find the web app's "bundle ID" on the URL looking something +you can find the web app's "bundle ID" on the URL looking something like: `web:NzE5YzVlZDktZjJjOS00Y2Y2LTkzNjQtZTM0ZmJhNjU0MmY3` - ![Web App Bundle ID](https://github.com/oddbit/firebase-alerts/raw/main/doc/images/web-app-bundle-id.png) +## Integrating Google Gemini API + +You can harness the capabilities of Google Gemini's Large Language Model (LLM) by configuring an API key. This will enable the extension to leverage LLM's power to analyze, clarify, and explain each alert in a more insightful and helpful manner. +Read the official documentation on how to retrieve an API key: https://ai.google.dev/tutorials/setup # Billing - + To install an extension, your project must be on the [Blaze (pay as you go) plan](https://firebase.google.com/pricing) - + - You will be charged a small amount (typically around $0.01/month) for the Firebase resources required by this extension (even if it is not used). - This extension uses other Firebase and Google Cloud Platform services, which have associated charges if you exceed the service’s no-cost tier: - - Cloud Functions (Node.js 16+ runtime. [See FAQs](https://firebase.google.com/support/faq#extensions-pricing)) \ No newline at end of file +- Cloud Functions (Node.js 16+ runtime. [See FAQs](https://firebase.google.com/support/faq#extensions-pricing)) diff --git a/extension.yaml b/extension.yaml index 774e93c..9b3b186 100644 --- a/extension.yaml +++ b/extension.yaml @@ -1,5 +1,5 @@ name: firebase-alerts -version: 0.0.13 +version: 0.0.14-alpha.4 specVersion: v1beta displayName: Firebase Alerts @@ -115,17 +115,19 @@ params: required: false immutable: false - - param: WEBHOOK_MANDATORY - label: Mandatory webhook - description: Mandatory webhook for Slack, Discord, or Google Chat + - param: WEBHOOK_URL + label: Webhook + description: Webhook URL for Slack, Discord, or Google Chat type: secret required: true + immutable: false - - param: WEBHOOK_OPTIONAL - label: Optional webhook - description: Optional, additional webhook for Slack, Discord, or Google Chat + - param: API_KEY_GEMINI + label: Google Gemini API key + description: You can create an API key in Google AI Studio. type: secret required: false + immutable: false resources: - name: anr diff --git a/functions/package.json b/functions/package.json index c6a25aa..fed8a22 100644 --- a/functions/package.json +++ b/functions/package.json @@ -18,6 +18,7 @@ }, "main": "lib/src/index.js", "dependencies": { + "@google/generative-ai": "^0.1.3", "firebase-admin": "^11.4.1", "firebase-functions": "^4.1.1", "request": "^2.88.2" diff --git a/functions/src/alerts/crashlytics.ts b/functions/src/alerts/crashlytics.ts index 34eab89..0ed8868 100644 --- a/functions/src/alerts/crashlytics.ts +++ b/functions/src/alerts/crashlytics.ts @@ -8,10 +8,11 @@ import {EnvConfig} from "../utils/env-config"; import {DiscordWebhook} from "../webhook-plugins/discord"; import {GoogleChatWebhook} from "../webhook-plugins/google-chat"; import {SlackWebhook} from "../webhook-plugins/slack"; +import {GeminiService} from "../services/gemini.service"; const functionOpts = { region: process.env.LOCATION, - secrets: ["WEBHOOK_MANDATORY", "WEBHOOK_OPTIONAL"], + secrets: ["WEBHOOK_URL", "API_KEY_GEMINI"], }; /** @@ -54,14 +55,8 @@ async function handleCrashlyticsEvent(appCrash: AppCrash): return; } - const webhooks: Webhook[] = EnvConfig.webhooks.map(webhookPluginFromUrl); - - if (webhooks.length === 0) { - throw new Error("No webhooks defined. Please reconfigure the extension!"); - } - const promises = []; - for (const webhook of webhooks) { + for (const webhook of EnvConfig.webhooks.map(webhookPluginFromUrl)) { logger.debug("[handleCrashlyticsEvent] Webhook", webhook); const crashlyticsMessage = webhook.createCrashlyticsMessage(appCrash); const webhookPayload = { @@ -90,8 +85,14 @@ async function handleCrashlyticsEvent(appCrash: AppCrash): export const anr = crashlytics.onNewAnrIssuePublished(functionOpts, async (event) => { logger.debug("onNewAnrIssuePublished", event); - + const appCrash = AppCrash.fromCrashlytics(event); + if (EnvConfig.apiKeyGemini) { + logger.debug("Call Gemini API for explanation"); + const geminiService = new GeminiService(EnvConfig.apiKeyGemini); + appCrash.explanation = await geminiService.explainCrash(event); + logger.debug("Gemini explanation", appCrash.explanation); + } appCrash.tags.push("critical"); @@ -99,11 +100,16 @@ export const anr = }); export const fatal = - crashlytics.onNewFatalIssuePublished(functionOpts, (event) => { + crashlytics.onNewFatalIssuePublished(functionOpts, async (event) => { logger.debug("onNewFatalIssuePublished", event); const appCrash = AppCrash.fromCrashlytics(event); - + if (EnvConfig.apiKeyGemini) { + logger.debug("Call Gemini API for explanation"); + const geminiService = new GeminiService(EnvConfig.apiKeyGemini); + appCrash.explanation = await geminiService.explainCrash(event); + logger.debug("Gemini explanation", appCrash.explanation); + } appCrash.tags.push("critical"); return handleCrashlyticsEvent(appCrash); @@ -111,19 +117,31 @@ export const fatal = export const nonfatal = - crashlytics.onNewNonfatalIssuePublished(functionOpts, (event) => { + crashlytics.onNewNonfatalIssuePublished(functionOpts, async (event) => { logger.debug("onNewNonfatalIssuePublished", event); const appCrash = AppCrash.fromCrashlytics(event); + if (EnvConfig.apiKeyGemini) { + logger.debug("Call Gemini API for explanation"); + const geminiService = new GeminiService(EnvConfig.apiKeyGemini); + appCrash.explanation = await geminiService.explainCrash(event); + logger.debug("Gemini explanation", appCrash.explanation); + } return handleCrashlyticsEvent(appCrash); }); export const regression = - crashlytics.onRegressionAlertPublished(functionOpts, (event) => { + crashlytics.onRegressionAlertPublished(functionOpts, async (event) => { logger.debug("onRegressionAlertPublished", event); const appCrash = AppCrash.fromCrashlytics(event); + if (EnvConfig.apiKeyGemini) { + logger.debug("Call Gemini API for explanation"); + const geminiService = new GeminiService(EnvConfig.apiKeyGemini); + appCrash.explanation = await geminiService.explainCrash(event); + logger.debug("Gemini explanation", appCrash.explanation); + } appCrash.tags.push("regression"); diff --git a/functions/src/index.ts b/functions/src/index.ts index fbf84f1..dfa2dd0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,6 +2,11 @@ // import * as billingFunctions from "./alerts/billing"; // import * as appDistributionFunctions from "./alerts/app-distribution"; +import * as admin from "firebase-admin"; + +admin.initializeApp(); + + export * from "./alerts/crashlytics"; // TODO: Uncomment when functions are supported. Keeping them commented out now diff --git a/functions/src/models/app-crash.ts b/functions/src/models/app-crash.ts index 64b8757..becde12 100644 --- a/functions/src/models/app-crash.ts +++ b/functions/src/models/app-crash.ts @@ -52,6 +52,7 @@ export class AppCrash implements IAppCrash { * @return {AppCrash} An instance of `AppCrash` class */ public static fromCrashlytics(event: SupportedCrashlyticsEvent): AppCrash { + const appCrash = { issueId: event.data.payload.issue.id, issueTitle: event.data.payload.issue.title, @@ -83,5 +84,6 @@ export class AppCrash implements IAppCrash { public readonly issueTitle: string; public readonly appId: string; public readonly appVersion: string; - public readonly tags = ["bug"]; + public readonly tags = ["bug"]; + public explanation?: string; } diff --git a/functions/src/services/gemini.service.ts b/functions/src/services/gemini.service.ts new file mode 100644 index 0000000..72abca2 --- /dev/null +++ b/functions/src/services/gemini.service.ts @@ -0,0 +1,69 @@ +import { + GoogleGenerativeAI, +} from "@google/generative-ai"; +import { SupportedCrashlyticsEvent } from "../models/app-crash"; + +/** + * Implements a service for interacting with Gemini LLM + */ +export class GeminiService { + constructor(apiKey: string) { + this.genAI = new GoogleGenerativeAI(apiKey); + } + + private genAI: GoogleGenerativeAI; + private static readonly MODEL_NAME = "gemini-pro"; + private static readonly GENERATION_CONFIG = { + temperature: 0.9, + topK: 1, + topP: 1, + maxOutputTokens: 2048, + }; + + // Block harmful content, hate speech etc + // Should not be needed or applicable to this use case + private static readonly SAFETY_SETTINGS = []; + + /** + * Make Gemini LLM explain a crashlytics event. + * + * @param {SupportedCrashlyticsEvent} appCrash App crash information + * @returns {Promise} Promise with the explanation + */ + async explainCrash(appCrash: SupportedCrashlyticsEvent): Promise { + const model = this.genAI.getGenerativeModel({ model: GeminiService.MODEL_NAME }); + + const promptContext = ` + @type defines the type of crashlytics issue. + + CrashlyticsRegressionAlertPayload is an issue that was previously fixed but has reappeared. + resolveTime - The time that the Crashlytics issues was most recently resolved before it began to reoccur. + + CrashlyticsNewNonfatalIssuePayload is a Non Fatal issue. + + CrashlyticsNewFatalIssuePayload is a Fatal issue. + + CrashlyticsNewAnrIssuePayload is an Application Not Responding issue. + `; + + const promptExplanation = ` + Explain the the following crashlytics issue for a software developer. + Explain the issue in a way that is easy to understand and actionable. + Use the following JSON information to explain the issue: + `; + + const crashJson = JSON.stringify(appCrash) + + const parts = [ + {text: [promptContext, promptExplanation, crashJson].join("\n\n")}, + ]; + + const result = await model.generateContent({ + contents: [{ role: "user", parts }], + generationConfig: GeminiService.GENERATION_CONFIG, + safetySettings: GeminiService.SAFETY_SETTINGS, + }); + + return result.response.text(); + } +} \ No newline at end of file diff --git a/functions/src/utils/env-config.ts b/functions/src/utils/env-config.ts index a863462..4db6928 100644 --- a/functions/src/utils/env-config.ts +++ b/functions/src/utils/env-config.ts @@ -92,11 +92,17 @@ export class EnvConfig { */ static get webhooks(): string[] { return [ - process.env.WEBHOOK_MANDATORY, - process.env.WEBHOOK_OPTIONAL, + process.env.WEBHOOK_URL, ].filter((x) => !!x) as string[]; } + /** + * Get Google Gemini API key + */ + static get apiKeyGemini(): string | undefined { + return process.env.API_KEY_GEMINI; + } + /** * Get an environment variable's value diff --git a/functions/src/utils/localization.ts b/functions/src/utils/localization.ts index cc1a9e9..e6a5b87 100644 --- a/functions/src/utils/localization.ts +++ b/functions/src/utils/localization.ts @@ -36,6 +36,18 @@ const l10n = { "openCrashlyticsIssue": { "en": "Open Crashlytics", }, + "labelCrashlytics": { + "en": "Crashlytics", + }, + "imgAltCrashlytics": { + "en": "Crashlytics logo", + }, + "labelFirebase": { + "en": "Firebase", + }, + "labelIssueTracker": { + "en": "Issue Tracker", + }, "createIssue": { "en": "Create Issue", }, diff --git a/functions/src/webhook-plugins/discord.ts b/functions/src/webhook-plugins/discord.ts index 3e7fc1e..2070455 100644 --- a/functions/src/webhook-plugins/discord.ts +++ b/functions/src/webhook-plugins/discord.ts @@ -34,7 +34,7 @@ export class DiscordWebhook extends Webhook { url: makeCrashlyticsIssueUrl(appCrash), color: 16763432, author: { - name: "Crashlytics", + name: l10n.translate("labelCrashlytics"), icon_url: crashlyticsImgUrl, }, fields: [ @@ -60,12 +60,12 @@ export class DiscordWebhook extends Webhook { // ========================================================================= // ========================================================================= - // Github Section + // Issue tracker Section // if (EnvConfig.repositoryUrl) { crashlyticsInfo.fields.push({ - name: "Repository", + name: l10n.translate("labelIssueTracker"), value: [ l10n.translate("descriptionCreateNewIssue"), `[${l10n.translate("createIssue")}]` + diff --git a/functions/src/webhook-plugins/google-chat.ts b/functions/src/webhook-plugins/google-chat.ts index 32eb95e..7251a6b 100644 --- a/functions/src/webhook-plugins/google-chat.ts +++ b/functions/src/webhook-plugins/google-chat.ts @@ -33,11 +33,11 @@ export class GoogleChatWebhook extends Webhook { cardId: Date.now() + "-" + Math.round((Math.random() * 10000)), card: { header: { - title: "Crashlytics", + title: l10n.translate("labelCrashlytics"), subtitle: l10n.translate(appCrash.issueType), imageUrl: crashlyticsImgUrl, imageType: "CIRCLE", - imageAltText: "Avatar for Crashlytics", + imageAltText: l10n.translate("imgAltCrashlytics"), }, sections: [ { @@ -68,7 +68,7 @@ export class GoogleChatWebhook extends Webhook { // Firebase section // const firebaseSection = { - header: "Firebase", + header: l10n.translate("labelFirebase"), widgets: [] as object[], }; googleChatCard.card.sections.push(firebaseSection); @@ -90,12 +90,12 @@ export class GoogleChatWebhook extends Webhook { // ========================================================================= // ========================================================================= - // Github Section + // Issue tracker Section // if (EnvConfig.repositoryUrl) { googleChatCard.card.sections.push({ - header: "Repository", + header: l10n.translate("labelIssueTracker"), widgets: [ { buttonList: { diff --git a/functions/src/webhook-plugins/slack.ts b/functions/src/webhook-plugins/slack.ts index 2ac81ad..b88fe25 100644 --- a/functions/src/webhook-plugins/slack.ts +++ b/functions/src/webhook-plugins/slack.ts @@ -29,7 +29,7 @@ export class SlackWebhook extends Webhook { type: "header", text: { type: "plain_text", - text: "Crashlytics", + text: l10n.translate("labelCrashlytics"), }, }, { @@ -40,24 +40,28 @@ export class SlackWebhook extends Webhook { text: [ `*${l10n.translate(appCrash.issueType)}*`, appCrash.issueTitle, - "*Bundle id*", + `*${l10n.translate("labelBundleId")}*`, "`" + EnvConfig.bundleId + "`", ].join("\n"), }, fields: [ { type: "mrkdwn", - text: "*Platform*\n`"+ EnvConfig.platform +"`", + text: ` + *${l10n.translate("labelPlatform")}* + \`${EnvConfig.platform}\``, }, { type: "mrkdwn", - text: "*Version*\n`"+ appCrash.appVersion +"`", + text: ` + *${l10n.translate("labelVersion")}* + \`${appCrash.appVersion}\``, }, ], accessory: { type: "image", image_url: crashlyticsImgUrl, - alt_text: "Crashlytics icon", + alt_text: l10n.translate("imgAltCrashlytics"), }, }, ] as object[], @@ -77,7 +81,7 @@ export class SlackWebhook extends Webhook { type: "header", text: { type: "plain_text", - text: "Firebase", + text: l10n.translate("labelFirebase"), }, }, @@ -105,7 +109,7 @@ export class SlackWebhook extends Webhook { // ========================================================================= // ========================================================================= - // Github Section + // Issue tracker Section // if (EnvConfig.repositoryUrl) { @@ -117,7 +121,7 @@ export class SlackWebhook extends Webhook { type: "header", text: { type: "plain_text", - text: "Repository", + text: l10n.translate("labelIssueTracker"), }, }, { @@ -132,9 +136,9 @@ export class SlackWebhook extends Webhook { type: "plain_text", text: l10n.translate("createIssue"), }, - value: "create_new_github_issue", + value: "create_new_issue", url: makeRepositoryIssueUrl(appCrash), - action_id: "button-action-create-github-issue", + action_id: "button-action-create-issue", }, }, { @@ -149,9 +153,9 @@ export class SlackWebhook extends Webhook { type: "plain_text", text: l10n.translate("searchIssue"), }, - value: "search_github_issue", + value: "search_issue", url: makeRepositorySearchUrl(appCrash), - action_id: "button-action-search-github-issue", + action_id: "button-action-search-issue", }, }, ], diff --git a/functions/yarn.lock b/functions/yarn.lock index 3d3b7a3..8771b7c 100644 --- a/functions/yarn.lock +++ b/functions/yarn.lock @@ -702,6 +702,11 @@ teeny-request "^8.0.0" uuid "^8.0.0" +"@google/generative-ai@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@google/generative-ai/-/generative-ai-0.1.3.tgz#8e529d4d86c85b64d297b4abf1a653d613a09a9f" + integrity sha512-Cm4uJX1sKarpm1mje/MiOIinM7zdUUrQp/5/qGPAgznbdd/B9zup5ehT6c1qGqycFcSopTA1J1HpqHS5kJR8hQ== + "@grpc/grpc-js@^1.0.0", "@grpc/grpc-js@^1.3.2", "@grpc/grpc-js@~1.7.0": version "1.7.3" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.7.3.tgz#f2ea79f65e31622d7f86d4b4c9ae38f13ccab99a"