Qlik Cloud reload alerts sent as emails
Description of the various kinds of alert emails Butler can send when an app reload fails in Qlik Cloud.
Butler offers a lot of flexibility when it comes to alerts when reloads fai in Qlik Sense Cloud.
Learn how to set up the desired features, the alert layout, formatting and more.
Warning
In general the information related to an app that failed reloading will not be very sensitive.
It’s app name, owner id, tenant id etc.
If this information is considered sensitive in your organization, you should consider the security implications of sending this information to service like Butler via the public Internet. The traffic will be https encrypted, but even so, the information will be sent over the public Internet.
As always, make sure to follow your organization’s security guidelines. Think before you act.
These alert types are available:
Alerts can be sent to these destinations, with different options available for each destination.
Each destination can be individually enabled/disabled in the config file.
Destination | App reload failure | Enable/disable alert per app | Alerts to app owners | Flexible formatting | Basic formatting | Comment | |
---|---|---|---|---|---|---|---|
✅ | ✅ | ✅ | ✅ | ||||
Slack | ✅ | ✅ | ✅ | ✅ | |||
MS Teams | ✅ | ✅ | ✅ | ✅ |
Somehow Butler needs to be notified when a reload task in Qlik Sense Cloud fails.
The only way to do this is currently (2024 October) to use Qlik Cloud’s outgoing webhooks, and have them triggered when the app reload fails.
So, the outbound webhook should call some URL it can reach.
In practice this means a URL on the public Internet.
This could be a Butler provided endpoint, but exposing Butler to the public Internet is not a good idea from a security perspective.
There are various ways to solve this, each described below.
More options for brining Qlik Cloud events to Butler may be added in the future.
While this solution may be seen as a bit complex, it does offer some advantages:
Downsides include:
The solution looks like this:
The webhook in Qlik Cloud is set up to call an Azure function when an app reload completes. The Azure function then sends an MQTT message to Butler.
The webhook is defined like this:
The webhook secret can be used in the gateway to verify that the webhook call is coming from an approved Qlik Cloud tenant.
Various solutions are possible, including:
If the basic assumption is that you want to expose as little attack surface on the Internet as possible, the solution will most likely involve some kind of intermediate service that can be reached by Qlik Cloud, and that can in turn asynchronously forward the event to Butler.
An Azure Function App is used in this example, but the same concept can be used with other cloud providers, or on-premise.
In the example below the Azure function is written in Node.js.
Note that the code below is a basic example that should be extended before being used in a production environment:
x-myheader-foo1
, but it does not check the value of that header. Room for improvement there.All in all the function does work, it has been in test use for some months and should serve well as a starting point for your own implementation.
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { connectAsync } from 'mqtt';
export async function qscloudreload(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
context.log(`Http function processed request for url "${request.url}"`);
context.log(`Request method: ${request.method}`);
// Get query string parameters
const query = Object.fromEntries(request.query.entries());
context.log(`Request query:\n${JSON.stringify(query, null, 2)}`);
// Ensure there are no query string parameters
if (Object.keys(query).length > 0) {
context.log('Too many query string parameters. Expected none.');
return {
status: 400,
body: 'Invalid query string parameters'
};
}
// -----------------------------------------------------
// Get headers
const headers = Object.fromEntries(request.headers.entries());
context.log(`Request headers:\n${JSON.stringify(headers, null, 2)}`);
// Ensure the correct headers are present
// The following headers are required:
// - accept-encoding: gzip
// - client-ip: <The IP address of the client making the request>
// - content-length: <The length of the request body>
// - content-type: application/json
// - host: <The host name of the function app>
// - qlik-signature: <The Qlik Sense Cloud signature of the request>
// - user-agent: Qlik Webhook
// - x-forwarded-proto: https
// - x-forwarded-tlsversion: 1.3
//
// Custom https headers (must also be present):
// - x-myheader-foo1: bar1
const requiredHeaders = [
'accept-encoding',
'client-ip',
'content-length',
'content-type',
'host',
'qlik-signature',
'user-agent',
'x-forwarded-proto',
'x-forwarded-tlsversion',
'x-myheader-foo1'
];
for (const header of requiredHeaders) {
if (!headers[header]) {
context.log(`Missing required header: ${header}`);
return {
status: 400,
body: `Missing required header`
};
}
}
// Make sure select headers contain correct values
// - accept-encoding: gzip
// - content-type: application/json
// - user-agent: Qlik Webhook
// - x-forwarded-proto: https
// - x-forwarded-tlsversion: 1.2 | 1.3
if (headers['accept-encoding'] !== 'gzip') {
context.log(`Invalid header value for accept-encoding: ${headers['accept-encoding']}`);
return {
status: 400,
body: `Invalid header value for accept-encoding`
};
}
if (headers['content-type'] !== 'application/json') {
context.log(`Invalid header value for content-type: ${headers['content-type']}`);
return {
status: 400,
body: `Invalid header value for content-type`
};
}
if (headers['user-agent'] !== 'Qlik Webhook') {
context.log(`Invalid header value for user-agent: ${headers['user-agent']}`);
return {
status: 400,
body: `Invalid header value for user-agent`
};
}
if (headers['x-forwarded-proto'] !== 'https') {
context.log(`Invalid header value for x-forwarded-proto: ${headers['x-forwarded-proto']}`);
return {
status: 400,
body: `Invalid header value for x-forwarded-proto`
};
}
if (headers['x-forwarded-tlsversion'] !== '1.2' && headers['x-forwarded-tlsversion'] !== '1.3') {
context.log(`Invalid header value for x-forwarded-tlsversion: ${headers['x-forwarded-tlsversion']}`);
return {
status: 400,
body: `Invalid header value for x-forwarded-tlsversion`
};
}
// -----------------------------------------------------
// Get request body
let body: any = JSON.parse(await request.text());
let bodyString = JSON.stringify(body, null, 2);
context.log(`Request body:\n${bodyString}`);
// Make sure the request body contains the expected properties
// The following properties are required:
// - cloudEventsVersion: 0.1
// - source: com.qlik/engine,
// - contentType: application/json,
// - eventId: b0f5c473-5dea-4d7e-a188-5e0b904cde33,
// - eventTime: 2024-07-27T13:57:27Z,
// - eventTypeVersion: 1.0.0,
// - eventType: com.qlik.v1.app.reload.finished,
// - extensions: <object with the following properties>
// - ownerId: <userID of the owner of the Qlik Sense resource that triggered the event>
// - tenantId: <tenantID of the Qlik Sense tenant that contains the Qlik Sense resource that triggered the event>
// - userId: <userID of the user that triggered the event>
// data: <object>
const requiredProperties = [
'cloudEventsVersion',
'source',
'contentType',
'eventId',
'eventTime',
'eventTypeVersion',
'eventType',
'extensions',
'data'
];
for (const property of requiredProperties) {
if (!body[property]) {
context.log(`Missing required body property: ${property}`);
return {
status: 400,
body: `Missing required body property`
};
}
}
// Make sure the extensions object contains the expected properties
// The following properties are required:
// - ownerId: <userID of the owner of the Qlik Sense resource that triggered the event>
// - tenantId: <tenantID of the Qlik Sense tenant that contains the Qlik Sense resource that triggered the event>
// - userId: <userID of the user that triggered the event>
const extensions = body.extensions;
const extensionsProperties = [
'ownerId',
'tenantId',
'userId'
];
for (const property of extensionsProperties) {
if (!extensions[property]) {
context.log(`Missing required extensions property in request body: ${property}`);
return {
status: 400,
body: `Missing required extensions property`
};
}
}
// Make sure select properties contain correct values
// - cloudEventsVersion: 0.1
// - contentType: application/json
if (body.cloudEventsVersion !== '0.1') {
context.log(`Invalid body value for cloudEventsVersion: ${body.cloudEventsVersion}`);
return {
status: 400,
body: `Invalid body value for cloudEventsVersion`
};
}
if (body.contentType !== 'application/json') {
context.log(`Invalid body value for contentType: ${body.contentType}`);
return {
status: 400,
body: `Invalid body value for contentType`
};
}
// -----------------------------------------------------
// Forward message to MQTT broker
const brokerHost = 'hostname.of.mqtt.broker';
const brokerPort = 8765;
const mqttClient = await connectAsync(`mqtts://${brokerHost}:${brokerPort}`, {
username: 'my-username',
password: 'my-password',
});
const topic = `qscloud/app/reload/${body?.extensions?.tenantId}`;
context.log(`Using MQTT topic: ${topic}`);
context.log('MQTT client connected');
mqttClient.publish(topic, bodyString, (err) => {
if (err) {
context.log(`Error publishing message: ${err}`);
}
});
context.log('Message published');
await mqttClient.endAsync();
context.log('MQTT client disconnected');
// Return a 200 response
return {
status: 200,
// body: `Body received:\n${bodyString}`
body: `OK. Message received.`
};
};
app.http('qscloudreload', {
methods: ['POST'],
authLevel: 'anonymous',
handler: qscloudreload
});
The alerts can be customized in the same ways as for Qlik Sense client-managed. More info at links below.
Description of the various kinds of alert emails Butler can send when an app reload fails in Qlik Cloud.
Description of how app reload failed alerts can be sent as Slack messages.
Description of how reload alerts can be sent as Microsoft Teams messages.