diff --git a/.changeset/twelve-baboons-tell.md b/.changeset/twelve-baboons-tell.md new file mode 100644 index 00000000..18bb8cd4 --- /dev/null +++ b/.changeset/twelve-baboons-tell.md @@ -0,0 +1,5 @@ +--- +"@pagopa/io-session-manager": minor +--- + +Enable AppInsights diff --git a/apps/session-manager/env.example b/apps/session-manager/env.example index e7db4e83..8e4e24b3 100644 --- a/apps/session-manager/env.example +++ b/apps/session-manager/env.example @@ -1,3 +1,12 @@ +################ +# AppInsinghts # +################ +APPINSIGHTS_CONNECTION_STRING="" +APPINSIGHTS_DISABLED=false +APPINSIGHTS_SAMPLING_PERCENTAGE=100 +# cloud_RoleName value; if undefined, WEBSITE_SITE_NAME will be used +APPINSIGHTS_CLOUD_ROLE_NAME="session-manager-dev" + API_URL="" API_KEY="" API_BASE_PATH="" diff --git a/apps/session-manager/package.json b/apps/session-manager/package.json index d9fae1d4..63092f8d 100644 --- a/apps/session-manager/package.json +++ b/apps/session-manager/package.json @@ -35,10 +35,10 @@ "@azure/data-tables": "^13.2.2", "@azure/storage-queue": "^12.16.0", "@pagopa/io-functions-app-sdk": "x", - "@pagopa/io-functions-commons": "^29.0.4", + "@pagopa/io-functions-commons": "^29.1.0", "@pagopa/io-spid-commons": "^13.5.0", - "@pagopa/ts-commons": "^13.1.0", - "applicationinsights": "^1.8.10", + "@pagopa/ts-commons": "^13.1.1", + "applicationinsights": "^2.9.5", "body-parser": "^1.20.2", "date-fns": "^1.30.1", "express": "^4.19.2", diff --git a/apps/session-manager/src/app.ts b/apps/session-manager/src/app.ts index 705618b8..af33d76f 100644 --- a/apps/session-manager/src/app.ts +++ b/apps/session-manager/src/app.ts @@ -1,4 +1,5 @@ /* eslint-disable turbo/no-undeclared-env-vars */ +import * as appInsights from "applicationinsights"; import passport from "passport"; import express from "express"; import { Express } from "express"; @@ -77,9 +78,7 @@ import { localStrategy } from "./auth/local-strategy"; import { FF_LOLLIPOP_ENABLED } from "./config/lollipop"; export interface IAppFactoryParameters { - // TODO: Add the right AppInsigns type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly appInsightsClient?: any; + readonly appInsightsClient?: appInsights.TelemetryClient; } export const newApp: ( @@ -87,7 +86,10 @@ export const newApp: ( // eslint-disable-next-line max-lines-per-function ) => Promise = async ({ appInsightsClient }) => { // Create the Session Storage service - const REDIS_CLIENT_SELECTOR = await RedisRepo.RedisClientSelector(!isDevEnv)( + const REDIS_CLIENT_SELECTOR = await RedisRepo.RedisClientSelector( + !isDevEnv, + appInsightsClient, + )( getRequiredENVVar("REDIS_URL"), process.env.REDIS_PASSWORD, process.env.REDIS_PORT, @@ -176,6 +178,7 @@ export const newApp: ( testLoginPassword, FF_LOLLIPOP_ENABLED, APIClients.fnLollipopAPIClient, + appInsightsClient, ), ); @@ -359,7 +362,9 @@ export const newApp: ( ...withSpidApp, spidConfigTime: TIMER.getElapsedMilliseconds(), })), - TE.chain(setupMetadataRefresherAndGS(REDIS_CLIENT_SELECTOR)), + TE.chain( + setupMetadataRefresherAndGS(REDIS_CLIENT_SELECTOR, appInsightsClient), + ), TE.chainFirst(checkIdpConfiguration), TE.chainFirstTaskK(applyErrorMiddleware), )(); diff --git a/apps/session-manager/src/auth/local-strategy.ts b/apps/session-manager/src/auth/local-strategy.ts index dd2e5f76..7fd5deb7 100644 --- a/apps/session-manager/src/auth/local-strategy.ts +++ b/apps/session-manager/src/auth/local-strategy.ts @@ -1,3 +1,4 @@ +import * as appInsights from "applicationinsights"; import { FiscalCode, NonEmptyString } from "@pagopa/ts-commons/lib/strings"; import * as passport from "passport"; import { Strategy } from "passport-local"; @@ -29,6 +30,7 @@ export const localStrategy = ( validPassword: string, isLollipopEnabled: boolean, lollipopApiClient: LollipopApiClient, + appInsightsTelemetryClient?: appInsights.TelemetryClient, ): passport.Strategy => new Strategy({ passReqToCallback: true }, (req, username, password, done) => { pipe( @@ -45,7 +47,12 @@ export const localStrategy = ( ), TE.chain(() => TE.tryCatch( - () => lollipopLoginHandler(isLollipopEnabled, lollipopApiClient)(req), + () => + lollipopLoginHandler( + isLollipopEnabled, + lollipopApiClient, + appInsightsTelemetryClient, + )(req), E.toError, ), ), diff --git a/apps/session-manager/src/config/appinsights.ts b/apps/session-manager/src/config/appinsights.ts new file mode 100644 index 00000000..620dd57c --- /dev/null +++ b/apps/session-manager/src/config/appinsights.ts @@ -0,0 +1,24 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ +import * as O from "fp-ts/Option"; +import * as E from "fp-ts/Either"; +import { pipe } from "fp-ts/lib/function"; +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; + +export const APPINSIGHTS_CONNECTION_STRING = O.fromNullable( + process.env.APPINSIGHTS_CONNECTION_STRING, +); + +export const APPINSIGHTS_CLOUD_ROLE_NAME = pipe( + process.env.APPINSIGHTS_CLOUD_ROLE_NAME, + NonEmptyString.decode, + E.getOrElseW(() => undefined), +); + +export const APPINSIGHTS_DISABLED = process.env.APPINSIGHTS_DISABLED === "true"; + +// Application insights sampling percentage +const DEFAULT_APPINSIGHTS_SAMPLING_PERCENTAGE = 5; +export const APPINSIGHTS_SAMPLING_PERCENTAGE = process.env + .APPINSIGHTS_SAMPLING_PERCENTAGE + ? parseInt(process.env.APPINSIGHTS_SAMPLING_PERCENTAGE, 10) + : DEFAULT_APPINSIGHTS_SAMPLING_PERCENTAGE; diff --git a/apps/session-manager/src/config/index.ts b/apps/session-manager/src/config/index.ts index 97e2e389..cecee6ba 100644 --- a/apps/session-manager/src/config/index.ts +++ b/apps/session-manager/src/config/index.ts @@ -18,6 +18,7 @@ import * as SpidConfig from "./spid"; import * as SpidLogConfig from "./spid-logs"; import * as ZendeskConfig from "./zendesk"; import * as PagoPAConfig from "./pagopa"; +import * as AppInsightsConfig from "./appinsights"; export const ENV = getNodeEnvironmentFromProcessEnv(process.env); @@ -46,4 +47,5 @@ export { SpidLogConfig, ZendeskConfig, PagoPAConfig, + AppInsightsConfig, }; diff --git a/apps/session-manager/src/server.ts b/apps/session-manager/src/server.ts index 9fc5b3d5..db94044a 100644 --- a/apps/session-manager/src/server.ts +++ b/apps/session-manager/src/server.ts @@ -1,16 +1,76 @@ +import { Server } from "http"; +import * as appInsights from "applicationinsights"; + +import * as O from "fp-ts/Option"; +import { pipe } from "fp-ts/lib/function"; + import { newApp } from "./app"; +import { AppInsightsConfig } from "./config"; +import { + StartupEventName, + initAppInsights, + trackStartupTime, +} from "./utils/appinsights"; import { log } from "./utils/logger"; +import { getCurrentBackendVersion } from "./utils/package"; +import { TimeTracer } from "./utils/timer"; + +const timer = TimeTracer(); // eslint-disable-next-line turbo/no-undeclared-env-vars const port = process.env.WEBSITES_PORT ?? 3000; -newApp({}) +const maybeAppInsightsClient = pipe( + AppInsightsConfig.APPINSIGHTS_CONNECTION_STRING, + O.map((key) => + initAppInsights(key, { + cloudRole: AppInsightsConfig.APPINSIGHTS_CLOUD_ROLE_NAME, + applicationVersion: getCurrentBackendVersion(), + disableAppInsights: AppInsightsConfig.APPINSIGHTS_DISABLED, + samplingPercentage: AppInsightsConfig.APPINSIGHTS_SAMPLING_PERCENTAGE, + }), + ), + O.toUndefined, +); + +newApp({ appInsightsClient: maybeAppInsightsClient }) .then((app) => { - app.listen(port, () => { - log.info(`Example app listening on port ${port}`); - }); + const server = app + .listen(port, () => { + const startupTimeMs = timer.getElapsedMilliseconds(); + + log.info("Listening on port %d", port); + log.info(`Startup time: %sms`, startupTimeMs.toString()); + pipe( + maybeAppInsightsClient, + O.fromNullable, + O.map((_) => + trackStartupTime(_, StartupEventName.SERVER, startupTimeMs), + ), + ); + }) + .on("close", () => { + log.info("On close: emit 'server:stop' event"); + const result = app.emit("server:stop"); + log.info( + `On close: end emit 'server:stop' event. Listeners found: ${result}`, + ); + + maybeAppInsightsClient?.flush(); + appInsights.dispose(); + }); + + process.on("SIGTERM", shutDown(server, "SIGTERM")); + process.on("SIGINT", shutDown(server, "SIGINT")); }) .catch((err) => { log.error("Error loading app: %s", err); process.exit(1); }); + +const shutDown = (server: Server, signal: string) => () => { + log.info(`${signal} signal received: closing HTTP server`); + server.close(() => { + log.info("HTTP server closed"); + }); +}; diff --git a/apps/session-manager/src/utils/appinsights.ts b/apps/session-manager/src/utils/appinsights.ts index ba02d3ed..0ac67772 100644 --- a/apps/session-manager/src/utils/appinsights.ts +++ b/apps/session-manager/src/utils/appinsights.ts @@ -1,10 +1,66 @@ import * as appInsights from "applicationinsights"; +import { + ApplicationInsightsConfig, + initAppInsights as startAppInsights, +} from "@pagopa/ts-commons/lib/appinsights"; import { hashFiscalCode } from "@pagopa/ts-commons/lib/hash"; import { User } from "../types/user"; const SESSION_TRACKING_ID_KEY = "session_tracking_id"; const USER_TRACKING_ID_KEY = "user_tracking_id"; +/** + * App Insights is initialized to collect the following informations: + * - Incoming API calls + * - Server performance information (CPU, RAM) + * - Unandled Runtime Exceptions + * - Outcoming API Calls (dependencies) + * - Realtime API metrics + */ +export function initAppInsights( + connectionString: string, + config: ApplicationInsightsConfig = {}, +): ReturnType { + const defaultClient = startAppInsights(connectionString, config); + defaultClient.addTelemetryProcessor(sessionIdPreprocessor); + + return defaultClient; +} + +export function sessionIdPreprocessor( + envelope: appInsights.Contracts.Envelope, + context?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [name: string]: any; + }, +): boolean { + if (context !== undefined) { + try { + const userTrackingId = + context.correlationContext.customProperties.getProperty( + USER_TRACKING_ID_KEY, + ); + if (userTrackingId !== undefined) { + // eslint-disable-next-line functional/immutable-data + envelope.tags[appInsights.defaultClient.context.keys.userId] = + userTrackingId; + } + const sessionTrackingId = + context.correlationContext.customProperties.getProperty( + SESSION_TRACKING_ID_KEY, + ); + if (sessionTrackingId !== undefined) { + // eslint-disable-next-line functional/immutable-data + envelope.tags[appInsights.defaultClient.context.keys.sessionId] = + sessionTrackingId; + } + } catch (e) { + // ignore errors caused by missing properties + } + } + return true; +} + /** * Attach the userid (CF) hash to the correlation context. * Also, if the user objects provides the session_tracking_id property, attach @@ -40,3 +96,22 @@ export function attachTrackingData(user: User): void { ); } } + +export enum StartupEventName { + SERVER = "api-backend.httpserver.startup", + SPID = "api-backend.spid.config", +} + +export const trackStartupTime = ( + telemetryClient: appInsights.TelemetryClient, + type: StartupEventName, + timeMs: bigint, +): void => { + telemetryClient.trackEvent({ + name: type, + properties: { + time: timeMs.toString(), + }, + tagOverrides: { samplingEnabled: "false" }, + }); +}; diff --git a/apps/session-manager/src/utils/express.ts b/apps/session-manager/src/utils/express.ts index c8fd2006..8bb262ab 100644 --- a/apps/session-manager/src/utils/express.ts +++ b/apps/session-manager/src/utils/express.ts @@ -1,3 +1,4 @@ +import * as appInsights from "applicationinsights"; import { IResponse, ResponseErrorInternal, @@ -13,6 +14,7 @@ import * as R from "fp-ts/Record"; import { getSpidStrategyOption } from "@pagopa/io-spid-commons/dist/utils/middleware"; import { RedisClientMode, RedisClientSelectorType } from "../types/redis"; import { IDP_METADATA_REFRESH_INTERVAL_SECONDS } from "../config/spid"; +import { StartupEventName, trackStartupTime } from "./appinsights"; import { log } from "./logger"; export type ExpressMiddleware = ( @@ -125,13 +127,20 @@ export const checkIdpConfiguration: ( */ export const setupMetadataRefresherAndGS: ( redisClientSelector: RedisClientSelectorType, + appInsightsClient?: appInsights.TelemetryClient, ) => (data: { app: express.Express; spidConfigTime: bigint; idpMetadataRefresher: () => T.Task; }) => TE.TaskEither = - (REDIS_CLIENT_SELECTOR) => (data) => { - // TODO: Add AppInsights startup track event + (REDIS_CLIENT_SELECTOR, appInsightsClient) => (data) => { + if (appInsightsClient) { + trackStartupTime( + appInsightsClient, + StartupEventName.SPID, + data.spidConfigTime, + ); + } log.info(`Spid init time: %sms`, data.spidConfigTime.toString()); // Schedule automatic idpMetadataRefresher const startIdpMetadataRefreshTimer = setInterval( @@ -147,19 +156,17 @@ export const setupMetadataRefresherAndGS: ( clearInterval(startIdpMetadataRefreshTimer); // Graceful redis connection shutdown. for (const client of REDIS_CLIENT_SELECTOR.select(RedisClientMode.ALL)) { - log.info(`Graceful closing redis connection`); - pipe( - O.fromNullable(client.quit), - O.map((redisQuitFn) => - redisQuitFn().catch((err) => - log.error( - `An Error occurred closing the redis connection: [${ - E.toError(err).message - }]`, - ), + log.info(`Gracefully closing redis connection`); + + client + .quit() + .catch((err) => + log.error( + `An Error occurred closing the redis connection: [${ + E.toError(err).message + }]`, ), - ), - ); + ); } }); return TE.of(data.app); diff --git a/apps/session-manager/src/utils/package.ts b/apps/session-manager/src/utils/package.ts new file mode 100644 index 00000000..80cac014 --- /dev/null +++ b/apps/session-manager/src/utils/package.ts @@ -0,0 +1,23 @@ +import * as E from "fp-ts/lib/Either"; +import * as t from "io-ts"; +import { pipe } from "fp-ts/lib/function"; +import * as packageJson from "../../package.json"; + +/** + * Parse the string value of a specified key from the package.json file. + * If it doesn't exists, returns 'UNKNOWN' + */ +export const getValueFromPackageJson = ( + key: keyof typeof packageJson, +): string => + pipe( + t.string.decode(packageJson[key]), + E.getOrElse(() => "UNKNOWN"), + ); + +/** + * Parse the current API version from the version field into the package.json file. + * If it doesn't exists, returns 'UNKNOWN' + */ +export const getCurrentBackendVersion = (): string => + getValueFromPackageJson("version"); diff --git a/docker/session-manager/env-dev b/docker/session-manager/env-dev index 9f131c8d..a0de0fdf 100644 --- a/docker/session-manager/env-dev +++ b/docker/session-manager/env-dev @@ -1,3 +1,13 @@ +################ +# AppInsinghts # +################ +APPINSIGHTS_CONNECTION_STRING="" +APPINSIGHTS_DISABLED=false +APPINSIGHTS_SAMPLING_PERCENTAGE=100 +# cloud_RoleName value; if undefined, WEBSITE_SITE_NAME will be used +APPINSIGHTS_CLOUD_ROLE_NAME="session-manager-dev" + + API_URL=http://functions-app:7071 API_KEY=key diff --git a/yarn.lock b/yarn.lock index 85c8e1de..82b9f896 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1169,9 +1169,9 @@ __metadata: languageName: node linkType: hard -"@pagopa/io-functions-commons@npm:^29.0.4": - version: 29.0.4 - resolution: "@pagopa/io-functions-commons@npm:29.0.4" +"@pagopa/io-functions-commons@npm:^29.1.0": + version: 29.1.0 + resolution: "@pagopa/io-functions-commons@npm:29.1.0" dependencies: "@azure/cosmos": "npm:^4.0.0" "@azure/data-tables": "npm:^13.2.2" @@ -1198,7 +1198,7 @@ __metadata: express: ^4.15.3 fp-ts: ^2.16.5 io-ts: ^2.2.21 - checksum: 10c0/f7b348ca9d4fc6f007b4364a06edfffb87aaef429244076abe11128ee24ded6b756b223203516960f325c112f1e474ce6e9b386cbc4475f496a29673fb34c380 + checksum: 10c0/fc02f048e632c1b4fe007a402ccdb7f947608ebad4c24146f2ddaa5fdf98cf32b49c7480e808eb543d8dcf0ee7bdb35aaab4e24e928b3a582afcf4d0704ca855 languageName: node linkType: hard @@ -1210,10 +1210,10 @@ __metadata: "@azure/storage-queue": "npm:^12.16.0" "@pagopa/eslint-config": "npm:^3.0.0" "@pagopa/io-functions-app-sdk": "npm:x" - "@pagopa/io-functions-commons": "npm:^29.0.4" + "@pagopa/io-functions-commons": "npm:^29.1.0" "@pagopa/io-spid-commons": "npm:^13.5.0" "@pagopa/openapi-codegen-ts": "npm:^13.2.0" - "@pagopa/ts-commons": "npm:^13.1.0" + "@pagopa/ts-commons": "npm:^13.1.1" "@pagopa/typescript-config-node": "npm:*" "@types/body-parser": "npm:^1" "@types/express": "npm:^4.17.21" @@ -1225,7 +1225,7 @@ __metadata: "@types/passport-local": "npm:^1.0.38" "@types/supertest": "npm:^6.0.2" "@vitest/coverage-v8": "npm:~1.5.0" - applicationinsights: "npm:^1.8.10" + applicationinsights: "npm:^2.9.5" body-parser: "npm:^1.20.2" date-fns: "npm:^1.30.1" dependency-check: "npm:^4.1.0" @@ -1375,6 +1375,27 @@ __metadata: languageName: node linkType: hard +"@pagopa/ts-commons@npm:^13.1.1": + version: 13.1.1 + resolution: "@pagopa/ts-commons@npm:13.1.1" + dependencies: + abort-controller: "npm:^3.0.0" + agentkeepalive: "npm:^4.1.4" + applicationinsights: "npm:^2.9.5" + jose: "npm:^4.15.5" + json-set-map: "npm:^1.1.2" + jsonwebtoken: "npm:^9.0.1" + node-fetch: "npm:^2.6.0" + semver: "npm:^7.5.2" + ulid: "npm:^2.3.0" + validator: "npm:^13.7.0" + peerDependencies: + fp-ts: ^2.16.5 + io-ts: ^2.2.21 + checksum: 10c0/9c00dde0b03f18aa743c4010104a5cb63d01b176bfbe8873e0292ca3540f0b7bcef207df952ffe08a0943b19cf3541572032926e81db98092a5096923babba9f + languageName: node + linkType: hard + "@pagopa/typescript-config-node@npm:*, @pagopa/typescript-config-node@workspace:packages/typescript-config-node": version: 0.0.0-use.local resolution: "@pagopa/typescript-config-node@workspace:packages/typescript-config-node"