diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb..db12f0f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Add performance monitoring triggers to v2 alerts (#1223). diff --git a/package.json b/package.json index 5ae1df4b8..a4a9711d5 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "./v2/alerts/appDistribution": "./lib/v2/providers/alerts/appDistribution.js", "./v2/alerts/billing": "./lib/v2/providers/alerts/billing.js", "./v2/alerts/crashlytics": "./lib/v2/providers/alerts/crashlytics.js", + "./v2/alerts/performance": "./lib/v2/providers/alerts/performance.js", "./v2/eventarc": "./lib/v2/providers/eventarc.js", "./v2/identity": "./lib/v2/providers/identity.js", "./v2/database": "./lib/v2/providers/database.js", @@ -125,6 +126,9 @@ "v2/alerts/crashlytics": [ "lib/v2/providers/alerts/crashlytics" ], + "v2/alerts/performance": [ + "lib/v2/providers/alerts/performance" + ], "v2/base": [ "lib/v2/base" ], diff --git a/spec/runtime/manifest.spec.ts b/spec/runtime/manifest.spec.ts index 7710c54a6..96a330914 100644 --- a/spec/runtime/manifest.spec.ts +++ b/spec/runtime/manifest.spec.ts @@ -1,5 +1,5 @@ -import { stackToWire, ManifestStack } from '../../src/runtime/manifest'; import { expect } from 'chai'; +import { ManifestStack, stackToWire } from '../../src/runtime/manifest'; import * as params from '../../src/v2/params'; describe('stackToWire', () => { diff --git a/spec/v2/providers/alerts/performance.spec.ts b/spec/v2/providers/alerts/performance.spec.ts new file mode 100644 index 000000000..99f99ddc7 --- /dev/null +++ b/spec/v2/providers/alerts/performance.spec.ts @@ -0,0 +1,168 @@ +import { expect } from 'chai'; +import * as alerts from '../../../../src/v2/providers/alerts'; +import * as performance from '../../../../src/v2/providers/alerts/performance'; +import { FULL_ENDPOINT, FULL_OPTIONS } from '../fixtures'; + +const APPID = '123456789'; +const myHandler = () => 42; + +const APP_EVENT_FILTER = { + appid: APPID, +}; + +describe('performance', () => { + describe('onThresholdAlertPublished', () => { + it('should create a function with alertType & appId', () => { + const func = performance.onThresholdAlertPublished(APPID, myHandler); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: alerts.eventType, + eventFilters: { + ...APP_EVENT_FILTER, + alerttype: performance.thresholdAlert, + }, + retry: false, + }, + }); + }); + + it('should create a function with opts', () => { + const func = performance.onThresholdAlertPublished( + { ...FULL_OPTIONS }, + myHandler + ); + + expect(func.__endpoint).to.deep.equal({ + ...FULL_ENDPOINT, + eventTrigger: { + eventType: alerts.eventType, + eventFilters: { + alerttype: performance.thresholdAlert, + }, + retry: false, + }, + }); + }); + + it('should create a function with appid in opts', () => { + const func = performance.onThresholdAlertPublished( + { ...FULL_OPTIONS, appId: APPID }, + myHandler + ); + + expect(func.__endpoint).to.deep.equal({ + ...FULL_ENDPOINT, + eventTrigger: { + eventType: alerts.eventType, + eventFilters: { + ...APP_EVENT_FILTER, + alerttype: performance.thresholdAlert, + }, + retry: false, + }, + }); + }); + + it('should create a function without opts or appId', () => { + const func = performance.onThresholdAlertPublished(myHandler); + + expect(func.__endpoint).to.deep.equal({ + platform: 'gcfv2', + labels: {}, + eventTrigger: { + eventType: alerts.eventType, + eventFilters: { + alerttype: performance.thresholdAlert, + }, + retry: false, + }, + }); + }); + + it('should create a function with a run method', () => { + const func = performance.onThresholdAlertPublished( + APPID, + (event) => event + ); + + const res = func.run('input' as any); + + expect(res).to.equal('input'); + }); + }); + + describe('getOptsAndApp', () => { + it('should parse a string', () => { + const [opts, appId] = performance.getOptsAndApp(APPID); + + expect(opts).to.deep.equal({}); + expect(appId).to.equal(APPID); + }); + + it('should parse an options object without appId', () => { + const myOpts: performance.PerformanceOptions = { + region: 'us-west1', + }; + + const [opts, appId] = performance.getOptsAndApp(myOpts); + + expect(opts).to.deep.equal({ region: 'us-west1' }); + expect(appId).to.be.undefined; + }); + + it('should parse an options object with appId', () => { + const myOpts: performance.PerformanceOptions = { + appId: APPID, + region: 'us-west1', + }; + + const [opts, appId] = performance.getOptsAndApp(myOpts); + + expect(opts).to.deep.equal({ region: 'us-west1' }); + expect(appId).to.equal(APPID); + }); + }); + + describe('convertPayload', () => { + it('should return the same payload', () => { + const payload = { + a: 'b', + conditionPercentile: 23, + appVersion: '3', + }; + + const convertedPayload = performance.convertPayload(payload as any); + + expect(convertedPayload).to.deep.eq(payload); + }); + + it('should return the same payload if the fields are undefined', () => { + const payload = { + a: 'b', + }; + + const convertedPayload = performance.convertPayload(payload as any); + + expect(convertedPayload).to.deep.eq({ + a: 'b', + }); + }); + + it('should remove fields', () => { + const payload = { + a: 'b', + conditionPercentile: 0, + appVersion: '', + }; + + const convertedPayload = performance.convertPayload(payload as any); + + expect(convertedPayload).to.deep.eq({ + a: 'b', + }); + }); + }); +}); diff --git a/src/runtime/manifest.ts b/src/runtime/manifest.ts index 062966985..d53165420 100644 --- a/src/runtime/manifest.ts +++ b/src/runtime/manifest.ts @@ -102,8 +102,8 @@ export interface ManifestStack { * @internal */ export function stackToWire(stack: ManifestStack): Object { - let wireStack = stack as any; - let traverse = function traverse(obj: Object) { + const wireStack = stack as any; + const traverse = function traverse(obj: Object) { for (const [key, val] of Object.entries(obj)) { if (val instanceof Expression) { obj[key] = val.toCEL(); diff --git a/src/v2/params/index.ts b/src/v2/params/index.ts index 67080f308..b638d68a5 100644 --- a/src/v2/params/index.ts +++ b/src/v2/params/index.ts @@ -27,14 +27,14 @@ import { BooleanParam, + Expression, FloatParam, IntParam, ListParam, Param, ParamOptions, - StringParam, SecretParam, - Expression, + StringParam, } from './types'; export { ParamOptions, Expression }; diff --git a/src/v2/params/types.ts b/src/v2/params/types.ts index 16d194fda..b138150db 100644 --- a/src/v2/params/types.ts +++ b/src/v2/params/types.ts @@ -46,7 +46,7 @@ export abstract class Expression< function quoteIfString( literal: T ): T { - //TODO(vsfan@): CEL's string escape semantics are slightly different than Javascript's, what do we do here? + // TODO(vsfan@): CEL's string escape semantics are slightly different than Javascript's, what do we do here? return typeof literal === 'string' ? (`"${literal}"` as T) : literal; } @@ -171,14 +171,14 @@ export interface SelectOptions { value: T; } -export type ParamSpec = { +export interface ParamSpec { name: string; default?: T; label?: string; description?: string; type: ParamValueType; input?: ParamInput; -}; +} export type ParamOptions = Omit, 'name' | 'type'>; @@ -219,8 +219,8 @@ export abstract class Param< } export class SecretParam { - name: string; static type: ParamValueType = 'secret'; + name: string; constructor(name: string) { this.name = name; diff --git a/src/v2/providers/alerts/alerts.ts b/src/v2/providers/alerts/alerts.ts index 6953e44b8..545e9bbcf 100644 --- a/src/v2/providers/alerts/alerts.ts +++ b/src/v2/providers/alerts/alerts.ts @@ -70,6 +70,7 @@ export type AlertType = | 'billing.automatedPlanUpdate' | 'appDistribution.newTesterIosDevice' | 'appDistribution.inAppFeedback' + | 'performance.threshold' | string; /** diff --git a/src/v2/providers/alerts/appDistribution.ts b/src/v2/providers/alerts/appDistribution.ts index 977af5450..695bfc4df 100644 --- a/src/v2/providers/alerts/appDistribution.ts +++ b/src/v2/providers/alerts/appDistribution.ts @@ -27,8 +27,8 @@ import { CloudEvent, CloudFunction } from '../../core'; import * as options from '../../options'; -import { FirebaseAlertData, getEndpointAnnotation } from './alerts'; import { Expression } from '../../params'; +import { FirebaseAlertData, getEndpointAnnotation } from './alerts'; /** * The internal payload object for adding a new tester device to app distribution. diff --git a/src/v2/providers/alerts/index.ts b/src/v2/providers/alerts/index.ts index d16ef00fe..e0988f5b8 100644 --- a/src/v2/providers/alerts/index.ts +++ b/src/v2/providers/alerts/index.ts @@ -30,6 +30,7 @@ import * as appDistribution from './appDistribution'; import * as billing from './billing'; import * as crashlytics from './crashlytics'; +import * as performance from './performance'; -export { appDistribution, billing, crashlytics }; +export { appDistribution, billing, crashlytics, performance }; export * from './alerts'; diff --git a/src/v2/providers/alerts/performance.ts b/src/v2/providers/alerts/performance.ts new file mode 100644 index 000000000..7fa82439f --- /dev/null +++ b/src/v2/providers/alerts/performance.ts @@ -0,0 +1,197 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/** + * Cloud functions to handle Firebase Performance Monitoring events from Firebase Alerts. + * @packageDocumentation + */ + +import { FirebaseAlertData, getEndpointAnnotation } from '.'; +import { CloudEvent, CloudFunction } from '../../core'; +import { EventHandlerOptions } from '../../options'; + +/** + * The internal payload object for a performance threshold alert. + * Payload is wrapped inside a {@link FirebaseAlertData} object. + */ +export interface ThresholdAlertPayload { + /* Name of the trace or network request this alert is for (e.g. my_custom_trace, firebase.com/api/123) */ + eventName: string; + /* The resource type this alert is for (i.e. trace, network request, screen rendering, etc.) */ + eventType: string; + /* The metric type this alert is for (i.e. success rate, response time, duration, etc.) */ + metricType: string; + /* The number of events checked for this alert condition */ + numSamples: number; + /* The threshold value of the alert condition without units (e.g. "75", "2.1") */ + thresholdValue: number; + /* The unit for the alert threshold (e.g. "percent", "seconds") */ + thresholdUnit: string; + /* The percentile of the alert condition, can be 0 if percentile is not applicable to the alert condition and omitted; range: [1, 100] */ + conditionPercentile?: number; + /* 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) */ + appVersion?: string; + /* The value that violated the alert condition (e.g. "76.5", "3") */ + violationValue: number; + /* The unit for the violation value (e.g. "percent", "seconds") */ + violationUnit: string; + /* The link to Fireconsole to investigate more into this alert */ + investigateUri: string; +} + +/** + * A custom CloudEvent for Firebase Alerts (with custom extension attributes). + * @typeParam T - the data type for performance alerts that is wrapped in a `FirebaseAlertData` object. + */ +export interface PerformanceEvent extends CloudEvent> { + /** The type of the alerts that got triggered. */ + alertType: string; + /** The Firebase App ID that’s associated with the alert. */ + appId: string; +} + +/** @internal */ +export const thresholdAlert = 'performance.threshold'; + +/** + * Configuration for app distribution functions. + */ +export interface PerformanceOptions extends EventHandlerOptions { + // Scope the function to trigger on a specific application. + appId?: string; +} + +/** + * Declares a function that can handle receiving performance threshold alerts. + * @param handler - Event handler which is run every time a threshold alert is received. + * @returns A function that you can export and deploy. + */ +export function onThresholdAlertPublished( + handler: ( + event: PerformanceEvent + ) => any | Promise +): CloudFunction>; + +/** + * Declares a function that can handle receiving performance threshold alerts. + * @param appId - A specific application the handler will trigger on. + * @param handler - Event handler which is run every time a threshold alert is received. + * @returns A function that you can export and deploy. + */ +export function onThresholdAlertPublished( + appId: string, + handler: ( + event: PerformanceEvent + ) => any | Promise +): CloudFunction>; + +/** + * Declares a function that can handle receiving performance threshold alerts. + * @param opts - Options that can be set on the function. + * @param handler - Event handler which is run every time a threshold alert is received. + * @returns A function that you can export and deploy. + */ +export function onThresholdAlertPublished( + opts: PerformanceOptions, + handler: ( + event: PerformanceEvent + ) => any | Promise +): CloudFunction>; + +/** + * Declares a function that can handle receiving performance threshold alerts. + * @param appIdOrOptsOrHandler - A specific application, options, or an event-handling function. + * @param handler - Event handler which is run every time a threshold alert is received. + * @returns A function that you can export and deploy. + */ +export function onThresholdAlertPublished( + appIdOrOptsOrHandler: + | string + | PerformanceOptions + | ((event: PerformanceEvent) => any | Promise), + handler?: ( + event: PerformanceEvent + ) => any | Promise +): CloudFunction> { + if (typeof appIdOrOptsOrHandler === 'function') { + handler = appIdOrOptsOrHandler as ( + event: PerformanceEvent + ) => any | Promise; + appIdOrOptsOrHandler = {}; + } + + const [opts, appId] = getOptsAndApp(appIdOrOptsOrHandler); + + const func = (raw: CloudEvent) => { + const event = raw as PerformanceEvent; + const convertedPayload = convertPayload(event.data.payload); + event.data.payload = convertedPayload; + return handler(event); + }; + + func.run = handler; + func.__endpoint = getEndpointAnnotation(opts, thresholdAlert, appId); + + return func; +} + +/** + * Helper function to parse the function opts and appId. + * @internal + */ +export function getOptsAndApp( + appIdOrOpts: string | PerformanceOptions +): [EventHandlerOptions, string | undefined] { + if (typeof appIdOrOpts === 'string') { + return [{}, appIdOrOpts]; + } + + const opts: EventHandlerOptions = { ...appIdOrOpts }; + const appId: string | undefined = appIdOrOpts.appId; + delete (opts as any).appId; + + return [opts, appId]; +} + +/** + * Helper function to convert the raw payload of a {@link PerformanceEvent} to a {@link ThresholdAlertPayload} + * @internal + */ +export function convertPayload( + raw: ThresholdAlertPayload +): ThresholdAlertPayload { + const payload: ThresholdAlertPayload = { ...raw }; + if ( + typeof payload.conditionPercentile !== 'undefined' && + payload.conditionPercentile === 0 + ) { + delete (payload as any).conditionPercentile; + } + if ( + typeof payload.appVersion !== 'undefined' && + payload.appVersion.length === 0 + ) { + delete (payload as any).appVersion; + } + + return payload; +} diff --git a/src/v2/providers/tasks.ts b/src/v2/providers/tasks.ts index 9883aea3e..efc99e273 100644 --- a/src/v2/providers/tasks.ts +++ b/src/v2/providers/tasks.ts @@ -38,8 +38,8 @@ import { RetryConfig, } from '../../common/providers/tasks'; import * as options from '../options'; -import { HttpsFunction } from './https'; import { Expression } from '../params'; +import { HttpsFunction } from './https'; export { AuthData, Request }; diff --git a/v2/alerts/performance.js b/v2/alerts/performance.js new file mode 100644 index 000000000..ae33ba821 --- /dev/null +++ b/v2/alerts/performance.js @@ -0,0 +1,26 @@ +// The MIT License (MIT) +// +// Copyright (c) 2022 Firebase +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// This file is not part of the firebase-functions SDK. It is used to silence the +// imports eslint plugin until it can understand import paths defined by node +// package exports. +// For more information, see github.com/import-js/eslint-plugin-import/issues/1810