Soundcheck integrations (also called Fact Collectors) are responsible for collecting data about your Backstage entities from third-party services. A single piece of data collected about an entity is called a Fact. Facts are then referenced in Soundcheck Checks to determine whether an entity adheres to the rules you’ve defined.Soundcheck comes with a set of pre-built integrations out-of-the-box, including GitHub, PagerDuty, Datadog, and more. However, you may need to create custom integrations to collect different types of data or connect to internal services specific to your organization.
It is not possible to extend or modify pre-built fact collectors shipped by
Soundcheck. If you need extra functionality, you must create a custom
integration or use the HTTP
integration
for simpler use cases.
This tutorial walks you through creating a custom integration from scratch, using the PagerDuty API as an example. The same pattern applies to any third-party API.What you’ll build: A fact collector called ExamplePagerDutyFactCollector that retrieves service details from PagerDuty for entities annotated with a pagerduty.com/service-id.Time to complete: ~30 minutes
Updates the configuration (called by Soundcheck on config changes)
getConfigSchema()
() → Promise<string>
Returns JSON schema for validating configuration
The ConfigurableFactCollector is useful for setting up API tokens, configuring the frequency of fact collection, and any other configuration options that may be specific to your integration.Periodically (~every minute), Soundcheck will sync the fact collector configs by calling getConfig to determine if the config has changed, and call setConfig if it has.
When to use which?
Use ConfigurableFactCollector if your integration needs runtime configuration updates (recommended for most use cases)
Use FactCollector for simpler integrations with static configuration that won’t change at runtime
The collect() method is the heart of your integration—it fetches data for each entity:
src/ExamplePagerDutyFactCollector.ts
async collect( entities: Entity[], params?: { factRefs?: FactRef[]; refresh?: FactRef[] },): Promise<Fact[]> { const facts: Fact[] = []; // Validate requested facts if (params?.factRefs) { const requested = params.factRefs.map(ref => stringifyFactRef(ref)); if (!requested.includes(SERVICE_FACT_REFERENCE)) { this.#logger.warn('Unsupported factRefs requested'); return []; } } // Collect facts for each entity for (const entity of entities) { const fact = await this.#collectServiceFact(entity); if (fact) { facts.push(fact); } } return facts;}async #collectServiceFact(entity: Entity): Promise<Fact | undefined> { // Only collect for entities with the PagerDuty annotation const serviceId = entity.metadata.annotations?.['pagerduty.com/service-id']; if (!serviceId) return undefined; const service = await this.#getService(serviceId, SERVICE_DETAILS); if (!service) return undefined; return { factRef: SERVICE_FACT_REFERENCE, entityRef: stringifyEntityRef(entity), data: service, timestamp: DateTime.utc().toISO()!, };}async #getService(serviceId: string, include?: string[]): Promise<any> { 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;}
📖 Understanding the collect() method
Method arguments:
Argument
Type
Description
entities
Entity[]
Array of Backstage entities for which to collect facts
params.factRefs
FactRef[]
Which facts to collect. If your integration supports multiple fact types, use this to filter
params.refresh
FactRef[]
Facts that should be fetched fresh (bypass any integration-level cache). This does not affect Soundcheck’s fact caching
How it works:
The collect() method iterates over all entities and calls #collectServiceFact() for each
#collectServiceFact() checks if the entity has a pagerduty.com/service-id annotation—only entities with this annotation will have facts collected
If the annotation exists, it calls #getService() to fetch data from PagerDuty
The fact is returned with factRef, entityRef, data, and timestamp
Helper functions:
Function
Purpose
#collectServiceFact(entity)
Extracts the PagerDuty service ID from the entity annotation and fetches the service details. Returns a Fact object or undefined if no annotation exists
#getService(serviceId, include?)
Makes the actual API call to PagerDuty. Constructs a query string with the requested fields and returns the service data
These methods tell Soundcheck what facts you can collect and their data structure:
Returns fact references this integration can collect. Used by Soundcheck’s frontend to show available facts in the No-Code UI when creating checks
getDataSchema()
string | undefined
Returns a JSON schema describing the structure of a fact’s data. Used by the NCUI to display available paths when creating checks
getFactNames()Returns an array of fact references your integration can collect. In this example, we only support one fact (SERVICE_FACT_REFERENCE), so we return a single-element array.getDataSchema()Returns a JSON schema that describes the shape of the data for a given fact. This enables the Soundcheck UI to show users which data paths are available when creating checks:
The schema doesn’t need to include every field returned by the API. Any paths
not in the schema simply won’t appear in the UI autocomplete—but you can still
use them in your checks.
The getCollectionConfigs method tells Soundcheck when and how to collect facts:
The getCollectionConfigs method returns an array of CollectionConfig objects. Soundcheck uses this method to determine:
How often to collect facts (frequency)
What facts to collect (factRefs)
For which entities to collect facts (filter/exclude)
Most integrations return a single CollectionConfig that applies to all facts, but you can return multiple configurations with different schedules or filters.In the code above, defaults from the top-level configuration are applied to each collect configuration if not specified individually—this allows users to set common defaults while overriding specific settings per fact type.The CollectionConfig type is defined in the @spotify/backstage-plugin-soundcheck-common package:
Property
Type
Description
factRefs
FactRef[]
The facts to which this collection configuration applies
Filter specifying which entities to exclude from collection
frequency
{ cron: string } or { minutes: number } or { hours: number }
How often to collect facts. If not provided, collection will not be scheduled
initialDelay
{ seconds: number } or { minutes: number }
Delay before first collection
batchSize
number
Number of entities to process at once. Defaults to 1
cache
CacheConfig
Cache settings for collected facts. If not provided, facts will not be cached
Example configurations:
# Every 5 minutesfrequency: minutes: 5# Daily at midnightfrequency: cron: "0 0 * * *"# Filter to production componentsfilter: kind: Component spec.lifecycle: production# Exclude experimental componentsexclude: metadata.tags: ['experimental']
These methods are from the ConfigurableFactCollector interface and handle runtime configuration updates:
src/ExamplePagerDutyFactCollector.ts
getConfig(): Promise<JsonValue | undefined> { return Promise.resolve(this.#rawConfig);}getConfigSchema(): Promise<string> { return Promise.resolve(JSON.stringify({ type: 'object', properties: { token: { type: 'string', title: 'PagerDuty API Token', description: 'The API token for the PagerDuty API', }, }, required: ['token'], }));}setConfig(config: JsonValue): Promise<void> { this.#rawConfig = config; this.#config = new ConfigReader(config as JsonObject); const token = this.#config.getOptionalString('token'); if (!token) { this.#logger.warn('No token provided, facts cannot be collected.'); } this.#pd = api({ token }); return Promise.resolve();}
📖 Configuration Validation with Zod
For production integrations, validate configuration with Zod:
import { z } from 'zod';const ExamplePagerDutyConfigSchema = z.object({ token: z.string().min(1, 'PagerDuty token is required'), frequency: z.object({ minutes: z.number().optional(), hours: z.number().optional(), cron: z.string().optional(), }).optional(), filter: z.record(z.unknown()).optional(), collects: z.array(z.object({ type: z.string(), frequency: z.object({ minutes: z.number().optional(), hours: z.number().optional(), }).optional(), filter: z.record(z.unknown()).optional(), })).optional(),});// In setConfig():setConfig(config: JsonValue): Promise<void> { const result = ExamplePagerDutyConfigSchema.safeParse(config); if (!result.success) { throw new InputError(`Invalid configuration: ${result.error.message}`); } // ... rest of implementation}
If you’ve followed tabs 1-6, you should now have this complete implementation. If not, you can copy and paste this entire snippet into src/ExamplePagerDutyFactCollector.ts:
src/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`;const SERVICE_DETAILS = [ 'escalation_policies', 'teams', 'auto_pause_notifications_parameters', 'integrations',];export class ExamplePagerDutyFactCollector implements ConfigurableFactCollector{ id = ID; name = 'Example PagerDuty Integration'; description = 'Collects service details from PagerDuty'; #rawConfig?: JsonValue; #config?: Config; #pd?: PartialCall; readonly #logger: LoggerService; private constructor(logger: LoggerService) { this.#logger = logger.child({ target: this.id }); } public static create(logger: LoggerService): ExamplePagerDutyFactCollector { return new ExamplePagerDutyFactCollector(logger); } async collect( entities: Entity[], params?: { factRefs?: FactRef[]; refresh?: FactRef[] }, ): Promise<Fact[]> { const facts: Fact[] = []; if (params?.factRefs) { const requested = params.factRefs.map((ref) => stringifyFactRef(ref)); if (!requested.includes(SERVICE_FACT_REFERENCE)) { this.#logger.warn('Unsupported factRefs requested'); return []; } } if (params?.refresh) { this.#logger.warn('Refresh is not supported for this collector'); } for (const entity of entities) { const fact = await this.#collectServiceFact(entity); if (fact) { facts.push(fact); } } return facts; } async #collectServiceFact(entity: Entity): Promise<Fact | undefined> { const serviceId = entity.metadata.annotations?.['pagerduty.com/service-id']; if (!serviceId) return undefined; const service = await this.#getService(serviceId, SERVICE_DETAILS); if (!service) return undefined; return { factRef: SERVICE_FACT_REFERENCE, entityRef: stringifyEntityRef(entity), data: service, timestamp: DateTime.utc().toISO()!, }; } async #getService(serviceId: string, include?: string[]): Promise<any> { 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; } async getFactNames(): Promise<string[]> { return [SERVICE_FACT_REFERENCE]; } async getDataSchema(factName: FactRef): Promise<string | undefined> { if (factName === SERVICE_FACT_REFERENCE.split('/')[1]) { return JSON.stringify({ title: 'Service Details', description: 'PagerDuty 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; } async getCollectionConfigs(): Promise<CollectionConfig[]> { const collects = this.#config?.getConfigArray('collects'); if (!this.#config || !collects?.length) { return []; } return collects.map((collect: Config) => ({ factRefs: [SERVICE_FACT_REFERENCE], filter: collect.getOptional('filter') ?? this.#config?.getOptional('filter'), exclude: collect.getOptional('exclude') ?? this.#config?.getOptional('exclude'), frequency: collect.getOptional('frequency') ?? this.#config?.getOptional('frequency'), initialDelay: collect.getOptional('initialDelay') ?? this.#config?.getOptional('initialDelay'), batchSize: collect.getOptional('batchSize') ?? this.#config?.getOptional('batchSize'), cache: collect.getOptional('cache') ?? this.#config?.getOptional('cache'), })); } getConfig(): Promise<JsonValue | undefined> { return Promise.resolve(this.#rawConfig); } getConfigSchema(): Promise<string> { return Promise.resolve( JSON.stringify({ type: 'object', properties: { token: { type: 'string', title: 'PagerDuty API Token', description: 'The API token for the PagerDuty API', }, }, required: ['token'], }), ); } setConfig(config: JsonValue): Promise<void> { this.#rawConfig = config; this.#config = new ConfigReader(config as JsonObject); const token = this.#config.getOptionalString('token'); if (!token) { this.#logger.warn('No token provided, facts cannot be collected.'); } this.#pd = api({ token }); return Promise.resolve(); }}
This code creates a Backstage backend module that extends the Soundcheck plugin. When registered:
The module declares a dependency on factCollectionExtensionPoint—an extension point exposed by Soundcheck that allows external modules to register custom fact collectors.
During initialization, it calls addFactCollector() to register our ExamplePagerDutyFactCollector with Soundcheck’s fact collection system.
Once registered, Soundcheck manages the collector’s lifecycle, including scheduling fact collection based on the frequency you define in app-config.yaml.
soundcheck: collectors: example-pagerduty: token: ${PAGERDUTY_TOKEN} # Your PagerDuty API token # Default settings for all collect configurations frequency: minutes: 5 initialDelay: seconds: 15 filter: kind: Component spec.lifecycle: production # What facts to collect collects: - type: Service
📖 Advanced: Multiple collection schedules
You can define multiple collection schedules with different filters and frequencies:
app-config.yaml
soundcheck: collectors: example-pagerduty: token: ${PAGERDUTY_TOKEN} frequency: minutes: 5 filter: kind: Component spec.lifecycle: production collects: # Collect every 5 minutes (uses defaults above) - type: Service # Collect every 30 minutes for specific tags - type: Service frequency: minutes: 30 filter: kind: Component metadata.tags: ['critical-service']
Add the PagerDuty service annotation to entities that should be tracked:
catalog-info.yaml
apiVersion: backstage.io/v1alpha1kind: Componentmetadata: name: my-service annotations: pagerduty.com/service-id: PXXXXXX # Your PagerDuty service IDspec: type: service lifecycle: production owner: team-a
soundcheck: # ... collectors config from Step 4 ... checks: - id: requires_service_status_to_be_active description: Requires PagerDuty service status to be active passedMessage: ✅ Service is active failedMessage: ❌ Service is not active rule: factRef: example-pagerduty:default/service path: $.status operator: equal value: active
Why no schedule on this check?Notice this check has no schedule property. This is intentional—Soundcheck automatically triggers any check when the facts it depends on are collected.Since this check references example-pagerduty:default/service in its factRef, it will run automatically whenever our ExamplePagerDutyFactCollector collects the service fact. The collector runs on the schedule we defined in Step 4 (e.g., every 5 minutes), so the check runs on that same cadence.This is the recommended approach: schedule fact collection, not checks. It ensures checks always evaluate fresh data and respects the rate-limiting settings configured for the collector.
soundcheck: # ... collectors and checks config from above ... tracks: - id: pagerduty_track name: PagerDuty ownerEntityRef: group:default/platform-team # Update with your team description: > Ensure components are properly configured for incident management. filter: catalog: spec.lifecycle: 'production' levels: - ordinal: 1 name: Incident Readiness description: Basic PagerDuty integration checks checks: - id: requires_service_status_to_be_active name: Service Status Active description: Verifies the PagerDuty service is active