add common handler decorator and metrics for buzzer client
All checks were successful
Build and push image for doorman-homeassistant / docker (push) Successful in 53s
Build and push Doorman UI / API / docker (push) Successful in 1m35s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 4s

This commit is contained in:
Martin Dimitrov 2024-12-15 13:00:48 -08:00
parent 1e25548b9d
commit a12c7bfb44
12 changed files with 215 additions and 30 deletions

View File

@ -15,7 +15,6 @@ jobs:
token: ${{ github.token }}
deploy-gitainer:
needs: docker
runs-on: ubuntu-22.04
steps:
- name: Call Gitainer stack webhooks
run: curl --request POST http://192.168.1.150:9080/api/stacks/doorman-homeassistant?pretty
uses: martin/chromart-gitea-actions/.gitea/workflows/gitainer-deploy.yaml@main
with:
stack_name: doorman-homeassistant

BIN
bun.lockb

Binary file not shown.

View File

@ -12,14 +12,15 @@
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.609.0",
"@twilio-labs/serverless-runtime-types": "^4.0.1",
"@twilio/runtime-handler": "1.3.0",
"discord.js": "^14.16.3",
"prom-client": "^15.1.3",
"twilio": "^3.56"
},
"devDependencies": {
"twilio-run": "^3.5.4",
"concurrently": "^9.1.0"
"concurrently": "^9.1.0",
"@twilio-labs/serverless-runtime-types": "^3.0.0"
},
"engines": {
"node": "18"

View File

@ -0,0 +1,91 @@
import { ServerlessCallback, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types";
import { PrometheusContentType, Registry, Pushgateway, Summary } from "prom-client";
import { DoormanLambdaContext } from "./DoormanHandlerContext";
export type BaseEvent = { request: { cookies: {}; headers: {}; }; }
export type DoormanLambda<T extends DoormanLambdaContext, U extends BaseEvent> = (
context: Parameters<ServerlessFunctionSignature<T, U>>[0],
event: Parameters<ServerlessFunctionSignature<T, U>>[1],
callback: Parameters<ServerlessFunctionSignature<T, U>>[2],
metricsRegistry: Registry<PrometheusContentType>,
) => void;
export enum CommonMetrics {
RUNTIME = "FunctionRuntime",
};
/**
* A decorator for twilio handlers. It provides a metrics registry and
* should implement timeout and cleanup jobs based on lambda timeout
* @param handler
*/
export function withMetrics<T extends DoormanLambdaContext, U extends BaseEvent>(
functionName: string,
handler: DoormanLambda<T, U>
): ServerlessFunctionSignature<T, U> {
return async (context, event, callback) => {
console.log("[CommonHandler] creating metrics registry");
const metricsRegistry = new Registry();
const pushGateway = new Pushgateway(context.PUSHGATEWAY_URL, {}, metricsRegistry);
metricsRegistry.registerMetric(new Summary({
name: CommonMetrics.RUNTIME,
help: "Runtime of the function",
}));
const summaryTimer = (metricsRegistry.getSingleMetric(CommonMetrics.RUNTIME) as Summary).startTimer();
const startTime = Date.now();
console.log(`[CommonHandler] started handler at ${startTime}`);
const handlerResponsePromise: Promise<Parameters<ServerlessCallback>> = new Promise(async (resolve, reject) => {
// intercept the callbackResult
let callbackResult: Parameters<ServerlessCallback> | undefined;
const tempCallback: ServerlessCallback = (err, payload) => {
callbackResult = [err, payload];
}
await handler(context, event, tempCallback, metricsRegistry);
if (!callbackResult) {
reject("No callback was given");
}
resolve(callbackResult as Parameters<ServerlessCallback>);
});
console.time("[CommonHandler] nested handler time");
const result = await handlerResponsePromise;
console.timeEnd("[CommonHandler] nested handler time");
const endTime = Date.now();
const remainingTime = 10000 - (endTime - startTime);
console.log(`[CommonHandler] there is ${remainingTime} ms left to send metrics`);
let metricsTimeout = setTimeout(() => {
console.log("[CommonHandler] cutting it too close, abandoning metrics");
callback(...result);
}, remainingTime - 250);
summaryTimer();
console.log("[CommonHandler] attempting to push metrics...");
try {
await pushGateway.push({
jobName: functionName,
groupings: {
stage: context.STAGE,
},
});
console.log("[CommonHandler] pushed metrics successfully");
} catch (e: any) {
console.log("[CommonHandler] failed to push metrics, quietly discarding them", e);
}
clearTimeout(metricsTimeout);
callback(...result);
};
};

View File

@ -0,0 +1,11 @@
import { EnvironmentVariables } from "@twilio-labs/serverless-runtime-types/types";
export enum Stage {
DEV = "dev",
PROD = "prod",
};
export interface DoormanLambdaContext extends EnvironmentVariables {
PUSHGATEWAY_URL: string;
STAGE: string;
};

View File

@ -3,3 +3,7 @@ DOORMAN_URL=https://doorman.chromart.cc
# twilio auth
ACCOUNT_SID=
AUTH_TOKEN=
# metrics
PUSHGATEWAY_URL=https://metrics.chromart.cc
STAGE=prod

View File

@ -11,21 +11,23 @@
"deploy": "twilio-run deploy --load-system-env --env .env.example --service-name buzzer --environment=prod --override-existing-project"
},
"dependencies": {
"@twilio-labs/serverless-runtime-types": "^3.0.0",
"@twilio/runtime-handler": "1.3.0",
"node-fetch": "2",
"twilio": "^3.56"
"node-fetch": "^2.7.0",
"prom-client": "^15.1.3",
"prometheus-remote-write": "^0.5.0",
"twilio": "^3.84.1"
},
"devDependencies": {
"@types/bun": "latest",
"concurrently": "^9.1.0",
"twilio-run": "^3.5.4"
"twilio-run": "^3.5.4",
"@twilio-labs/serverless-runtime-types": "^3.0.0"
},
"engines": {
"node": "18"
},
"type": "commonjs",
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.2.2"
}
}

View File

@ -15,8 +15,14 @@ import VoiceResponse from 'twilio/lib/twiml/VoiceResponse';
import { DoorStatus } from '../../../doorman-api/src/types/DoorStatus';
import { StatusResponse } from '../../../doorman-api/src/functions/api/door/status';
import { InfoResponseClient } from '../../../doorman-api/src/functions/api/door/info';
import { withMetrics } from '../../../doorman-api/src/common/DoormanHandler';
import { Counter, Summary } from 'prom-client';
import { BuzzerActivatedMetrics, registerMetrics } from '../metrics/BuzzerActivatedMetrics';
export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent> = withMetrics('buzzer-activated', async function(context, event, callback, metricsRegistry) {
// metrics
registerMetrics(metricsRegistry);
export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent> = async function(context, event, callback) {
let invokeId = `[${randomUUID()}]`;
let configString = event.config;
let config: InfoResponseClient | undefined;
@ -37,6 +43,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
if (!config || !config.door) {
let twiml = new Twilio.twiml.VoiceResponse();
twiml.reject();
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.CALL_REJECTED) as Counter).inc({ From: event.From }, 1);
callback(null, twiml);
return;
}
@ -47,7 +54,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
msg + u + ')'
);
await notifyDiscord(context, msgs, config.discordUsers, config.discordUsers.map(() => ""));
await notifyDiscord(context, msgs, config.discordUsers, config.discordUsers.map(() => ""), metricsRegistry);
let discordLock = false;
let intervals: Timer[] = [];
@ -55,10 +62,14 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
const unlockPromise = new Promise<VoiceResponse>((resolve, reject) => {
intervals.push(setInterval(() => {
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.POLL_ATTEMPTS) as Counter).inc({ door: config.door }, 1);
const recordPollLatency = (metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.POLL_LATENCY) as Summary).startTimer({ door: config.door });
fetch(context.DOORMAN_URL + `/api/door/status?door=${config.door}`)
.then(res => res.json())
.then(async (rawBody) => {
let body = rawBody as StatusResponse;
recordPollLatency();
if (body?.status === DoorStatus.OPEN) {
clearInterval(intervals[0]);
const twiml = doorOpenTwiml(config);
@ -68,7 +79,9 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
console.log(
invokeId, "UnlockPromise: I was the fastest, so I will attempt to notify discord users before resolving with unlock"
);
await notifyAllDiscord(context, config, `🔓 Doorman buzzed someone up @ Door "${config.door}"`, JSON.stringify(body.fingerprint));
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.API_UNLOCK) as Counter).inc({ door: config.door }, 1);
await notifyAllDiscord(context, config, `🔓 Doorman buzzed someone up @ Door "${config.door}"`, metricsRegistry, JSON.stringify(body.fingerprint));
resolve(twiml);
} else {
console.log(
@ -86,6 +99,7 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
if (!discordLock) {
discordLock = true;
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.DIAL_THROUGH) as Counter).inc({ door: config.door }, 1);
console.log(
invokeId, "GracefulFallbackPromise: I was the fastest, so I will attempt to notify discord users before resolving with a call"
@ -93,7 +107,8 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
await notifyAllDiscord(
context,
config,
`📞 Somebody buzzed the door and it dialed through to fallback phone numbers @ Door "${config.door}"`
`📞 Somebody buzzed the door and it dialed through to fallback phone numbers @ Door "${config.door}"`,
metricsRegistry,
);
resolve(twiml);
} else {
@ -106,6 +121,9 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
const ungracefulFallbackPromise = new Promise<VoiceResponse>((resolve, reject) => {
timeouts.push(setTimeout(async () => {
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.RESULT_NOTIFICATION_FATE_UNKNOWN) as Counter).inc({ door: config.door }, 1);
(metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.DIAL_THROUGH) as Counter).inc({ door: config.door }, 1);
const twiml = dialFallbackTwiml(config);
console.error(
invokeId, "UngracefulFallbackPromise: Cutting it too close to timeout! Skipping notifying users and calling fallback"
@ -119,4 +137,4 @@ export const handler: ServerlessFunctionSignature<TwilioContext, BuzzerDialEvent
timeouts.forEach(timeout => clearTimeout(timeout));
intervals.forEach(interval => clearInterval(interval));
callback(null, twiml);
};
});

View File

@ -0,0 +1,54 @@
import { Counter, Registry, Summary } from "prom-client";
export enum BuzzerActivatedMetrics {
CALL_REJECTED = "CallRejected",
API_UNLOCK = "ApiUnlocked",
DIAL_THROUGH = "DialThrough",
RESULT_NOTIFICATION_FATE_UNKNOWN = "ResultNotificationFateUnknown",
POLL_ATTEMPTS = "PollAttempts",
POLL_LATENCY = "PollLatency",
NOTIFY_LATENCY = "NotifyLatency",
}
export const registerMetrics = (metricsRegistry: Registry) => {
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.CALL_REJECTED,
help: "A call is rejected because the dialer / door is not registered",
labelNames: ["From"],
}));
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.API_UNLOCK,
help: "Door was unlocked with the API",
labelNames: ["door"],
}));
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.DIAL_THROUGH,
help: "Dialed through to fallback numbers",
labelNames: ["door"],
}));
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.RESULT_NOTIFICATION_FATE_UNKNOWN,
help: "Discord result notification may or may not have been delivered due to time constraints",
labelNames: ["door"],
}));
metricsRegistry.registerMetric(new Counter({
name: BuzzerActivatedMetrics.POLL_ATTEMPTS,
help: "Number of times the door status was polled",
labelNames: ["door"],
}));
metricsRegistry.registerMetric(new Summary({
name: BuzzerActivatedMetrics.POLL_LATENCY,
help: "Latency for the door status poll",
labelNames: ["door"],
}));
metricsRegistry.registerMetric(new Summary({
name: BuzzerActivatedMetrics.NOTIFY_LATENCY,
help: "Latency for notify api calls",
}));
}

View File

@ -1,5 +1,5 @@
import { EnvironmentVariables } from "@twilio-labs/serverless-runtime-types/types";
import { DoormanLambdaContext } from "../../../doorman-api/src/common/DoormanHandlerContext";
export interface TwilioContext extends EnvironmentVariables {
export interface TwilioContext extends DoormanLambdaContext {
DOORMAN_URL: string;
}
};

View File

@ -1,8 +1,10 @@
import { register, Registry, Summary } from "prom-client";
import { InfoResponseClient } from "../../../doorman-api/src/functions/api/door/info";
import { TwilioContext } from "../types/TwilioContext";
import { DoorConfig } from "../types/DoorConfig";
import { lambdaFastHttp } from "./LambdaUtils";
import { BuzzerActivatedMetrics } from "../metrics/BuzzerActivatedMetrics";
export async function getConfig(context: TwilioContext, buzzer: string): Promise<DoorConfig | undefined> {
export async function getConfig(context: TwilioContext, buzzer: string): Promise<InfoResponseClient | undefined> {
return await fetch(context.DOORMAN_URL + `/api/door/info?buzzer=${buzzer}`)
.then(res => res.json())
.catch(err => {
@ -10,12 +12,15 @@ export async function getConfig(context: TwilioContext, buzzer: string): Promise
});
}
export function notifyDiscord(context: TwilioContext, msg: string[], u: string[], optionalJsonStr: string[]){
return lambdaFastHttp(context.DOORMAN_URL +
export async function notifyDiscord(context: TwilioContext, msg: string[], u: string[], optionalJsonStr: string[], metricsRegistry: Registry){
const endTimer = (metricsRegistry.getSingleMetric(BuzzerActivatedMetrics.NOTIFY_LATENCY) as Summary).startTimer();
const res = await lambdaFastHttp(context.DOORMAN_URL +
`/api/door/notify?discordUser=${encodeURIComponent(JSON.stringify(u))}&msg=${encodeURIComponent(JSON.stringify(msg))}&json=${encodeURIComponent(JSON.stringify(optionalJsonStr))}`,
).catch(err => console.log(err))
).catch(err => console.log(err));
endTimer();
return res;
}
export async function notifyAllDiscord(context: TwilioContext, config: DoorConfig, msg: string, optionalJsonStr: string = "") {
return notifyDiscord(context, config.discordUsers.map(() => msg), config.discordUsers, config.discordUsers.map(() => optionalJsonStr));
export async function notifyAllDiscord(context: TwilioContext, config: InfoResponseClient, msg: string, metricsRegistry: Registry, optionalJsonStr: string = "") {
return notifyDiscord(context, config.discordUsers.map(() => msg), config.discordUsers, config.discordUsers.map(() => optionalJsonStr), metricsRegistry);
}

View File

@ -1,7 +1,7 @@
import VoiceResponse from 'twilio/lib/twiml/VoiceResponse';
import { DoorConfig } from '../types/DoorConfig';
import { InfoResponseClient } from '../../../doorman-api/src/functions/api/door/info';
export function doorOpenTwiml(config: DoorConfig): VoiceResponse {
export function doorOpenTwiml(config: InfoResponseClient): VoiceResponse {
const twiml = new Twilio.twiml.VoiceResponse();
twiml.play('https://buzzer-2439-prod.twil.io/buzzing_up_boosted.mp3');
@ -12,7 +12,7 @@ export function doorOpenTwiml(config: DoorConfig): VoiceResponse {
return twiml;
}
export function dialFallbackTwiml(config: DoorConfig): VoiceResponse {
export function dialFallbackTwiml(config: InfoResponseClient): VoiceResponse {
const twiml = new Twilio.twiml.VoiceResponse();
let dial = twiml.dial({