Skip to main content

PagerDuty TPI - Code

Introduction

Below is the code for the custom PagerDuty TPI. This code is a TypeScript class that implements the ConfigurableFactCollector interface from the Soundcheck library. It collects facts about PagerDuty services and provides configuration options for the collector.

For more information on how to use this code, please refer to the walkthrough.

ExamplePagerDutyFactCollector.ts
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { api } from '@pagerduty/pdjs';
import {
CollectionConfig,
Fact,
FactRef,
stringifyFactRef,
} from '@spotify/backstage-plugin-soundcheck-common';
import { ConfigurableFactCollector } from '@spotify/backstage-plugin-soundcheck-node';
import { DateTime } from 'luxon';
import qs from 'qs';
import { JsonObject, JsonValue } from '@backstage/types';
import { Config, ConfigReader } from '@backstage/config';
import { PartialCall } from '@pagerduty/pdjs/build/src/api';
import { LoggerService } from '@backstage/backend-plugin-api';

const ID = 'example_pagerduty';
const SCOPE = 'default';
const SERVICE_FACT_REFERENCE = `${ID}:${SCOPE}/service`;

/**
* Sent to the pagerduty API to get the service details.
*/
const SERVICE_DETAILS = [
'escalation_policies',
'teams',
'auto_pause_notifications_parameters',
'integrations',
];

/**
* An example integration with PagerDuty.
*/
export class ExamplePagerDutyFactCollector
implements ConfigurableFactCollector
{
id = ID;
name = 'Example PagerDuty Integration';
description = 'PagerDuty integration!';

/**
* The configuration given to this collector.
*/
#rawConfig?: JsonValue;

/**
* The reader for the configuration.
* @private
*/
#config?: Config;

/**
* The PagerDuty API client.
* @private
*/
#pd?: PartialCall;

readonly #logger: LoggerService;

constructor(logger: LoggerService) {
this.#logger = logger.child({
target: this.id,
});
}

/**
* Collects the requested facts for the given entities.
* @param entities
* @param params
*/
async collect(
entities: Entity[],
params?: {
factRefs?: FactRef[];
refresh?: FactRef[];
},
): Promise<Fact[]> {
// The facts that we'll return
const facts: Fact[] = [];

// If this collector supported multiple facts, we could switch on which facts to collect
// based on the factRefs parameter. Since we only support one fact, we'll just collect it
// (see below) and log a warning if the requested factRefs are not supported.
if (params?.factRefs) {
if (
!params.factRefs
.map((value) => stringifyFactRef(value))
.includes(SERVICE_FACT_REFERENCE)
) {
this.#logger.warn(
'Unsupported factRefs requested in PagerDutyFactCollector',
);
return [];
}
}

// Likewise, if this collector supported refreshing facts, we would implement logic here to
// ensure that the facts we're collecting aren't served from any sort of (local) cache (separate
// from the fact cache that Soundcheck uses). Since we don't support refreshing, we'll just log
// a warning if the refresh parameter is set.
if (params?.refresh) {
this.#logger.warn('Refresh is not supported for this collector');
}

// Collect for each entity.
for (const entity of entities) {
const fact = await this.#collectServiceFact(entity);
if (fact) {
facts.push(fact);
}
}
return facts;
}

/**
* Collects the service details for the given entity.
* @param entity
* @private
*/
async #collectServiceFact(entity: Entity): Promise<Fact | undefined> {
// Note: This collector will only collect facts for entities which have
// this annotation. If the entity does not define a service-id, we will not collect a fact.
// This is how the entity (probably a production component) is associated with a PagerDuty service.
const serviceId = entity.metadata.annotations?.['pagerduty.com/service-id'];
if (serviceId) {
const service = await this.#getService(serviceId, SERVICE_DETAILS);
if (service) {
// Return the collected data in the shape of a Fact:
return {
factRef: SERVICE_FACT_REFERENCE,
entityRef: stringifyEntityRef(entity),
data: service,
timestamp: DateTime.utc().toISO()!,
};
}
}
return undefined;
}

/**
* Fetches the service details for the given service ID.
* @param serviceId
* @param include
* @private
*/
async #getService(serviceId: string, include?: string[]): Promise<any> {
// If our configuration wasn't set or didn't include a PD token, we can't make the request.
if (!this.#pd) return undefined;

const query = qs.stringify(
{
'include[]': include,
},
{
indices: false,
},
);

const { data } = await this.#pd.get(`/services/${serviceId}?${query}`);
return data?.service;
}

/**
* Return the names of the facts that this collector can collect. In this example, we only support
* one fact, so we return that fact's reference.
*/
async getFactNames(): Promise<string[]> {
return [SERVICE_FACT_REFERENCE];
}

/**
* Return the data schema for the requested fact. This is a JSON schema that describes the shape of
* the data that will be returned for the fact. In this example we're just returning a static schema
* that describes (some of) the shape of the data that we're collecting for the single supported
* pagerduty fact.
*
* This is used by the front-end to display path options for the selected fact.
* If the entire shape of the fact is not represented, it only means that any excluded paths
* won't show up in the front end, you can still use any data within the fact in your checks.
*
* @param factName the name of the fact for which to return the schema
*/
async getDataSchema(factName: FactRef): Promise<string | undefined> {
// Given that this collector only supports a single fact, let's ensure that the 'service'
// fact is being requested, and if so, respond with the schema.
if (factName === SERVICE_FACT_REFERENCE.split('/')[1]) {
return JSON.stringify({
title: 'Service Details',
description: 'Pager Duty Service Details',
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
description: {
type: 'string',
},
status: {
type: 'string',
},
escalation_policies: {
type: 'object',
},
teams: {
type: 'array',
},
acknowledgement_timeout: {
type: 'number',
},
integrations: {
type: 'object',
},
},
});
}
return undefined;
}

/**
* Return a CollectionConfig that describes the fact that we collect.
*
* This is used by Soundcheck to schedule the collection of facts for all
* configurations which have a 'frequency' field.
*
* A collection config is defined as:
*
* type CollectionConfig = {
* factRefs?: FactRef[];
* filter?: EntityFilter;
* exclude?: EntityFilter;
* frequency?:
* | {
* cron: string;
* }
* | HumanDuration;
* initialDelay?: Duration | HumanDuration;
* batchSize?: number;
* cache?: CacheConfig;
* };
*
*/
async getCollectionConfigs(): Promise<CollectionConfig[]> {
// Nothing to collect if we don't have a config or if there are no collects configured.
const collects = this.#config?.getConfigArray('collects');
if (this.#config === undefined || !collects || collects?.length === 0) {
return [];
}

// Use any defaults in the configuration for this collector to apply to all Collect configurations:
return collects.map((collect: Config) => {
const collectionConfig = collect;
return {
factRefs: [SERVICE_FACT_REFERENCE], // We only support one fact.
filter:
collectionConfig.getOptional('filter') ??
this.#config?.getOptional('filter') ??
undefined,
exclude:
collectionConfig.getOptional('exclude') ??
this.#config?.getOptional('exclude') ??
undefined,
frequency:
collectionConfig.getOptional('frequency') ??
this.#config?.getOptional('frequency') ??
undefined,
initialDelay:
collectionConfig.getOptional('initialDelay') ??
this.#config?.getOptional('initialDelay') ??
undefined,
batchSize:
collectionConfig.getOptional('batchSize') ??
this.#config?.getOptional('batchSize') ??
undefined,
cache:
collectionConfig.getOptional('cache') ??
this.#config?.getOptional('cache') ??
undefined,
};
});
}

/**
* Return the configuration for this collector. This is a JSON object that describes the configuration
* for the collector, so we'll return the configuration that was last passed to the collector.
*/
getConfig(): Promise<JsonValue | undefined> {
return this.#rawConfig
? Promise.resolve(this.#rawConfig)
: Promise.resolve(undefined);
}

/**
* Return the configuration schema for this collector. This is a JSON schema that describes the shape
* of the configuration that this collector expects.
*
* Typically, we'd have a Zod schema that's also used to validate the config sent to setConfig()
* and we'd return something like 'JSON.stringify(zodToJsonSchema(ExamplePagerDutyConfigSchema))'
* but for this example, we'll just return a tiny portion of the schema for brevity.
*/
getConfigSchema(): Promise<string> {
return Promise.resolve(
JSON.stringify({
type: 'object',
properties: {
token: {
type: 'string',
title: 'PagerDuty API Token',
description: 'The API token for the Pager Duty API',
},
},
required: ['token'],
}),
);
}

/**
* Set the configuration for this collector. This is called by Soundcheck on initialization and
* when the configuration for the collector is updated.
*
* Typically, you'd validate the configuration with a Zod schema here, but for this example, we'll
* just store the configuration and log a warning if the token is missing, even though collection
* and scheduling still depend on the configuration.
*
* @param config
*/
setConfig(config: JsonValue): Promise<void> {
this.#rawConfig = config;
this.#config = new ConfigReader(config as JsonObject);

if (!this.#config) {
this.#logger.warn(
'No configuration provided for ExamplePagerDutyFactCollector',
);
}
const token = this.#config.getOptionalString('token');
if (token === undefined) {
this.#logger.warn(
'No token provided for ExamplePagerDutyFactCollector, facts cannot be collected.',
);
}
this.#pd = api({ token });

return Promise.resolve();
}
}