sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
406 lines • 19.9 kB
JavaScript
import { SfError } from "@salesforce/core";
import c from "chalk";
import { NotifProviderRoot } from "./notifProviderRoot.js";
import { getCurrentGitBranch, getGitRepoName, uxLog } from "../utils/index.js";
import { UtilsNotifs } from "./utils.js";
import { CONSTANTS, getEnvVar } from "../../config/index.js";
import { getSeverityIcon, removeMarkdown } from "../utils/notifUtils.js";
import { GitProvider } from "../gitProvider/index.js";
import axios from "axios";
import fs from "fs-extra";
import * as path from "path";
const MAX_LOKI_LOG_LENGTH = Number(process.env.MAX_LOKI_LOG_LENGTH || 200000);
const TRUNCATE_LOKI_ELEMENTS_LENGTH = Number(process.env.TRUNCATE_LOKI_ELEMENTS_LENGTH || 500);
export class ApiProvider extends NotifProviderRoot {
apiUrl;
payload;
payloadFormatted;
metricsApiUrl;
jsonLogsFile;
metricsPayload;
metricsFormat = 'influx';
getLabel() {
return "sfdx-hardis Api connector";
}
// Always send notifications to API endpoint
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isApplicableForNotif(notifMessage) {
return true;
}
isUserNotifProvider() {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async postNotification(notifMessage) {
const apiPromises = []; // Use Promises to optimize performances with api calls
this.apiUrl = getEnvVar("NOTIF_API_URL");
this.jsonLogsFile = getEnvVar("NOTIF_API_LOGS_JSON_FILE");
if (this.apiUrl == null && this.jsonLogsFile == null) {
throw new SfError("[ApiProvider] You need to define a variable NOTIF_API_URL or NOTIF_API_LOGS_JSON_FILE to use sfdx-hardis Api notifications");
}
// Build initial payload data from notifMessage
await this.buildPayload(notifMessage);
// Format payload according to API endpoint: for example, Grafana loki
await this.formatPayload();
// Send notif logs
this.managePostLogs(apiPromises, notifMessage);
// Handle Metrics API if provided
this.managePostMetrics(apiPromises, notifMessage);
await Promise.allSettled(apiPromises);
return;
}
managePostLogs(apiPromises, notifMessage) {
const notifApiSkipLogs = getEnvVar("NOTIF_API_SKIP_LOGS");
if (notifApiSkipLogs === "all") {
uxLog("log", this, `[ApiProvider] Skipped posting logs to API and JSON file as NOTIF_API_SKIP_LOGS is set to 'all'`);
return;
}
else if (notifApiSkipLogs && notifApiSkipLogs?.split(",").includes(notifMessage.type)) {
uxLog("log", this, `[ApiProvider] Skipped posting logs to API and JSON file for notification type '${notifMessage.type}' as NOTIF_API_SKIP_LOGS includes it`);
return;
}
if (this.apiUrl !== null) {
apiPromises.push(this.sendToApi());
}
// Write logs to JSON file if configured
if (this.jsonLogsFile !== null) {
apiPromises.push(this.writeLogsToJsonFile(this.jsonLogsFile));
}
}
managePostMetrics(apiPromises, notifMessage) {
this.metricsApiUrl = getEnvVar("NOTIF_API_METRICS_URL");
if (this.metricsApiUrl !== null) {
const notifApiSkipMetrics = getEnvVar("NOTIF_API_SKIP_METRICS");
if (notifApiSkipMetrics === "all") {
uxLog("log", this, `[ApiProvider] Skipped posting metrics to API as NOTIF_API_SKIP_METRICS is set to 'all'`);
return;
}
else if (notifApiSkipMetrics && notifApiSkipMetrics?.split(",").includes(notifMessage.type)) {
uxLog("log", this, `[ApiProvider]Skipped posting metrics to API for notification type '${notifMessage.type}' as NOTIF_API_SKIP_METRICS includes it`);
return;
}
// Detect metrics format based on URL
this.detectMetricsFormat();
this.buildMetricsPayload();
if (this.metricsPayload.length > 0) {
apiPromises.push(this.sendToMetricsApi());
}
}
}
// Build message
async buildPayload(notifMessage) {
const firstLineMarkdown = UtilsNotifs.prefixWithSeverityEmoji(UtilsNotifs.slackToTeamsMarkdown(notifMessage.text.split("\n")[0]), notifMessage.severity);
const logTitle = removeMarkdown(firstLineMarkdown);
let logBodyText = UtilsNotifs.prefixWithSeverityEmoji(UtilsNotifs.slackToTeamsMarkdown(notifMessage.text), notifMessage.severity);
// Add text details
if (notifMessage?.attachments?.length && notifMessage?.attachments?.length > 0) {
let text = "\n\n";
for (const attachment of notifMessage.attachments) {
if (attachment.text) {
text += attachment.text + "\n\n";
}
}
if (text !== "") {
logBodyText += UtilsNotifs.slackToTeamsMarkdown(text) + "\n\n";
}
}
// Add action blocks
if (notifMessage.buttons?.length && notifMessage.buttons?.length > 0) {
logBodyText += "Links:\n\n";
for (const button of notifMessage.buttons) {
// Url button
if (button.url) {
logBodyText += " - " + button.text + ": " + button.url + "\n\n";
}
}
logBodyText += "\n\n";
}
// Add sfdx-hardis ref
logBodyText += `Powered by sfdx-hardis: ${CONSTANTS.DOC_URL_ROOT}`;
logBodyText = removeMarkdown(logBodyText);
// Build payload
const repoName = (await getGitRepoName() || "").replace(".git", "");
const currentGitBranch = await getCurrentGitBranch();
const conn = globalThis.jsForceConn;
const monitoringKeyOverride = getEnvVar("SFDX_HARDIS_MONITORING_KEY") || getEnvVar("MONITORING_KEY");
const orgIdentifier = monitoringKeyOverride
? monitoringKeyOverride
: (conn.instanceUrl) ? conn.instanceUrl.replace("https://", "").replace(".my.salesforce.com", "").replace(/\./gm, "__") : currentGitBranch || "ERROR apiProvider";
const notifKey = orgIdentifier + "!!" + notifMessage.type;
this.payload = {
source: "sfdx-hardis",
type: notifMessage.type,
orgIdentifier: orgIdentifier,
gitIdentifier: `${repoName}/${currentGitBranch}`,
severity: notifMessage.severity,
data: Object.assign(notifMessage.data, {
_dateTime: new Date().toISOString(),
_severityIcon: getSeverityIcon(notifMessage.severity),
_title: logTitle,
_logBodyText: logBodyText,
_logElements: notifMessage.logElements,
_metrics: notifMessage.metrics,
_metricsKeys: Object.keys(notifMessage.metrics),
_notifKey: notifKey,
}),
};
// Add job url if available
const jobUrl = await GitProvider.getJobUrl();
if (jobUrl) {
this.payload.data._jobUrl = jobUrl;
}
}
async formatPayload() {
if ((this.apiUrl || "").includes("loki/api/v1/push")) {
await this.formatPayloadLoki();
return;
}
this.payloadFormatted = this.payload;
}
async formatPayloadLoki() {
const currentTimeNanoseconds = Date.now() * 1000 * 1000;
const payloadCopy = Object.assign({}, this.payload);
delete payloadCopy.data;
let payloadDataJson = JSON.stringify(this.payload.data);
const bodyBytesLen = new TextEncoder().encode(payloadDataJson).length;
// Truncate log elements if log entry is too big
if (bodyBytesLen > MAX_LOKI_LOG_LENGTH) {
const newPayloadData = Object.assign({}, this.payload.data);
const logElements = newPayloadData._logElements;
if (logElements.length > TRUNCATE_LOKI_ELEMENTS_LENGTH) {
const truncatedLogElements = logElements.slice(0, TRUNCATE_LOKI_ELEMENTS_LENGTH);
newPayloadData._logElements = truncatedLogElements;
newPayloadData._logElementsTruncated = true;
payloadDataJson = JSON.stringify(newPayloadData);
uxLog("log", this, c.grey(`[ApiProvider] Truncated _logElements from ${logElements.length} to ${truncatedLogElements.length} to avoid Loki entry max size reached (initial size: ${bodyBytesLen} bytes)`));
}
else {
newPayloadData._logBodyText = (newPayloadData._logBodyText || "").slice(0, 100) + "\n ... (truncated)";
payloadDataJson = JSON.stringify(newPayloadData);
uxLog("log", this, c.grey(`[ApiProvider] Truncated _logBodyText to 100 to avoid Loki entry max size reached (initial size: ${bodyBytesLen} bytes)`));
}
}
this.payloadFormatted = {
streams: [
{
stream: payloadCopy,
values: [[`${currentTimeNanoseconds}`, payloadDataJson]],
},
],
};
}
// Call remote API
async sendToApi() {
const axiosConfig = {
responseType: "json",
};
// Basic Auth
if (getEnvVar("NOTIF_API_BASIC_AUTH_USERNAME") != null) {
axiosConfig.auth = {
username: getEnvVar("NOTIF_API_BASIC_AUTH_USERNAME") || "",
password: getEnvVar("NOTIF_API_BASIC_AUTH_PASSWORD") || "",
};
}
// Bearer token
else if (getEnvVar("NOTIF_API_BEARER_TOKEN") != null) {
axiosConfig.headers = { Authorization: `Bearer ${getEnvVar("NOTIF_API_BEARER_TOKEN")}` };
}
// POST message
try {
const axiosResponse = await axios.post(this.apiUrl || "", this.payloadFormatted, axiosConfig);
const httpStatus = axiosResponse.status;
if (httpStatus > 200 && httpStatus < 300) {
uxLog("log", this, c.cyan(`[ApiProvider] Posted message to API ${this.apiUrl} (${httpStatus})`));
if (getEnvVar("NOTIF_API_DEBUG") === "true") {
uxLog("log", this, c.cyan(JSON.stringify(this.payloadFormatted, null, 2)));
}
}
}
catch (e) {
uxLog("warning", this, c.yellow(`[ApiProvider] Error while sending message to API ${this.apiUrl}: ${e.message}`));
uxLog("log", this, c.grey("Request body: \n" + JSON.stringify(this.payloadFormatted)));
uxLog("log", this, c.grey("Response body: \n" + JSON.stringify(e?.response?.data || {})));
}
}
// Write logs to JSON file in Loki-compatible newline-delimited JSON (NDJSON) format
// Writes one line per log element in full Loki push format for easy ingestion
async writeLogsToJsonFile(filePath) {
try {
await fs.ensureDir(path.dirname(filePath));
const timestamp = new Date().toISOString();
// Always write flat aggregate format
const logEntry = {
timestamp: timestamp,
source: this.payload.source,
type: this.payload.type,
severity: this.payload.severity,
orgIdentifier: this.payload.orgIdentifier,
gitIdentifier: this.payload.gitIdentifier,
metric: this.payload.data.metric,
_metrics: this.payload.data._metrics,
_metricsKeys: this.payload.data._metricsKeys,
_logElements: this.payload.data._logElements || [],
_title: this.payload.data._title,
_jobUrl: this.payload.data._jobUrl,
...(this.payload.data.limits && { limits: this.payload.data.limits }),
};
await fs.appendFile(filePath, JSON.stringify(logEntry) + '\n');
uxLog("log", this, c.cyan(`[ApiProvider] Appended log entry to file ${filePath}`));
const elementCount = this.payload.data._logElements?.length || 0;
const metricValue = this.payload.data.metric || 0;
uxLog("log", this, c.cyan(`[ApiProvider] Appended aggregate log entry (metric: ${metricValue}, elements: ${elementCount})`));
if (getEnvVar("NOTIF_API_DEBUG") === "true") {
uxLog("log", this, c.grey(`Log elements count: ${elementCount}`));
uxLog("log", this, c.grey(`Metric value: ${metricValue}`));
}
}
catch (e) {
uxLog("warning", this, c.yellow(`[ApiProvider] Error writing logs: ${e.message}`));
}
}
// Detect metrics format based on URL pattern
detectMetricsFormat() {
const metricsUrl = this.metricsApiUrl || "";
// Force prometheus format if explicitly set
const forceFormat = getEnvVar("NOTIF_API_METRICS_FORMAT");
if (forceFormat === "prometheus" || forceFormat === "influx") {
this.metricsFormat = forceFormat;
return;
}
// Auto-detect based on URL patterns
if (metricsUrl.includes("/metrics/job/") || metricsUrl.includes("pushgateway")) {
this.metricsFormat = "prometheus";
}
else if (metricsUrl.includes("influx") || metricsUrl.includes("grafana.net")) {
this.metricsFormat = "influx";
}
else {
// Default to influx for backward compatibility
this.metricsFormat = "influx";
}
uxLog("log", this, c.grey(`[ApiProvider] Detected metrics format: ${this.metricsFormat}`));
}
// Build metrics payload in either InfluxDB or Prometheus format
buildMetricsPayload() {
if (this.metricsFormat === "prometheus") {
this.buildMetricsPayloadPrometheus();
}
else {
this.buildMetricsPayloadInflux();
}
}
// Build InfluxDB line protocol: MetricName,source=sfdx-hardis,orgIdentifier=hardis-group metric=12.7,min=0,max=70,percent=0.63
buildMetricsPayloadInflux() {
// Build tag field
const metricTags = `source=${this.payload.source},` +
`type=${this.payload.type},` +
`orgIdentifier=${this.payload.orgIdentifier},` +
`gitIdentifier=${this.payload.gitIdentifier}`;
// Add extra fields and value
const metricsPayloadLines = [];
for (const metricId of Object.keys(this.payload.data._metrics)) {
const metricData = this.payload.data._metrics[metricId];
let metricPayloadLine = metricId + "," + metricTags + " ";
if (typeof metricData === "number") {
metricPayloadLine += "metric=" + metricData.toFixed(2);
metricsPayloadLines.push(metricPayloadLine);
}
else if (typeof metricData === "object") {
const metricFields = [];
if (metricData.min) {
metricFields.push("min=" + metricData.min.toFixed(2));
}
if (metricData.max) {
metricFields.push("max=" + metricData.max.toFixed(2));
}
if (metricData.percent) {
metricFields.push("percent=" + metricData.percent.toFixed(2));
}
metricFields.push("metric=" + metricData.value.toFixed(2));
metricPayloadLine += metricFields.join(",");
metricsPayloadLines.push(metricPayloadLine);
}
}
// Result as single string with carriage returns
this.metricsPayload = metricsPayloadLines.join("\n");
}
// Build Prometheus format
buildMetricsPayloadPrometheus() {
// Build labels
const labels = `source="${this.payload.source}",` +
`type="${this.payload.type}",` +
`orgIdentifier="${this.payload.orgIdentifier}",` +
`gitIdentifier="${this.payload.gitIdentifier}"`;
const metricsPayloadLines = [];
for (const metricId of Object.keys(this.payload.data._metrics)) {
const metricData = this.payload.data._metrics[metricId];
const sanitizedMetricName = metricId.replace(/[^a-zA-Z0-9_]/g, '_');
if (typeof metricData === "number") {
// Simple metric
metricsPayloadLines.push(`# TYPE ${sanitizedMetricName} gauge`);
metricsPayloadLines.push(`${sanitizedMetricName}{${labels}} ${metricData.toFixed(2)}`);
}
else if (typeof metricData === "object") {
// Complex metric with multiple values
if (metricData.value !== undefined) {
metricsPayloadLines.push(`# TYPE ${sanitizedMetricName} gauge`);
metricsPayloadLines.push(`${sanitizedMetricName}{${labels}} ${metricData.value.toFixed(2)}`);
}
if (metricData.min !== undefined) {
metricsPayloadLines.push(`# TYPE ${sanitizedMetricName}_min gauge`);
metricsPayloadLines.push(`${sanitizedMetricName}_min{${labels}} ${metricData.min.toFixed(2)}`);
}
if (metricData.max !== undefined) {
metricsPayloadLines.push(`# TYPE ${sanitizedMetricName}_max gauge`);
metricsPayloadLines.push(`${sanitizedMetricName}_max{${labels}} ${metricData.max.toFixed(2)}`);
}
if (metricData.percent !== undefined) {
metricsPayloadLines.push(`# TYPE ${sanitizedMetricName}_percent gauge`);
metricsPayloadLines.push(`${sanitizedMetricName}_percent{${labels}} ${metricData.percent.toFixed(2)}`);
}
}
}
this.metricsPayload = metricsPayloadLines.join("\n") + "\n";
}
// Call remote API
async sendToMetricsApi() {
const axiosConfig = {
responseType: "json",
};
// Set content type based on format
if (this.metricsFormat === "prometheus") {
axiosConfig.headers = { 'Content-Type': 'text/plain; version=0.0.4' };
}
// Basic Auth
if (getEnvVar("NOTIF_API_METRICS_BASIC_AUTH_USERNAME") != null) {
axiosConfig.auth = {
username: getEnvVar("NOTIF_API_METRICS_BASIC_AUTH_USERNAME") || "",
password: getEnvVar("NOTIF_API_METRICS_BASIC_AUTH_PASSWORD") || "",
};
}
// Bearer token
else if (getEnvVar("NOTIF_API_METRICS_BEARER_TOKEN") != null) {
if (!axiosConfig.headers) {
axiosConfig.headers = {};
}
axiosConfig.headers.Authorization = `Bearer ${getEnvVar("NOTIF_API_METRICS_BEARER_TOKEN")}`;
}
// POST message
try {
const axiosResponse = await axios.post(this.metricsApiUrl || "", this.metricsPayload, axiosConfig);
const httpStatus = axiosResponse.status;
if (httpStatus >= 200 && httpStatus < 300) {
uxLog("log", this, c.cyan(`[ApiMetricProvider] Posted metrics to API ${this.metricsApiUrl} (${httpStatus}) [format: ${this.metricsFormat}]`));
if (getEnvVar("NOTIF_API_DEBUG") === "true") {
uxLog("log", this, c.cyan("Metrics payload:\n" + this.metricsPayload));
}
}
}
catch (e) {
uxLog("warning", this, c.yellow(`[ApiMetricProvider] Error while sending metrics to API ${this.metricsApiUrl}: ${e.message}`));
uxLog("log", this, c.grey("Request body:\n" + this.metricsPayload));
uxLog("log", this, c.grey("Response body: " + JSON.stringify(e?.response?.data || {})));
}
}
}
//# sourceMappingURL=apiProvider.js.map