diff --git a/integrations/datadog/README.md b/integrations/datadog/README.md new file mode 100644 index 00000000..f79bc5a4 --- /dev/null +++ b/integrations/datadog/README.md @@ -0,0 +1,46 @@ +# Datadog Integration for CORE + +Connects your Datadog account to CORE, syncing monitor alerts and infrastructure events as activities. + +## Auth + +API Key + Application Key. Both are required. + +You will need: +- **DD-API-KEY** — found in Datadog → Organization Settings → API Keys +- **DD-APPLICATION-KEY** — found in Datadog → Organization Settings → Application Keys +- **Region** — one of `US1` (default), `US3`, `US5`, `EU`, `AP1` + +Region → base URL mapping: + +| Region | Base URL | +|--------|----------| +| US1 | `https://api.datadoghq.com` | +| US3 | `https://us3.datadoghq.com` | +| US5 | `https://us5.datadoghq.com` | +| EU | `https://api.datadoghq.eu` | +| AP1 | `https://ap1.datadoghq.com` | + +## Sync + +Polls every 15 minutes using incremental timestamp-based sync. + +- **Monitors**: fetches all monitors; surfaces those in ALERT or WARN state as activities. +- **Events**: fetches events since the last sync timestamp using cursor-based pagination (100 events per page). + +Activities tracked: +- Monitor state changes (ALERT, WARN, NO DATA) +- Infrastructure events (deployments, restarts, custom events) + +## Build + +```bash +pnpm install +pnpm build +``` + +## Register + +```bash +DATABASE_URL= npx ts-node scripts/register.ts +``` diff --git a/integrations/datadog/package.json b/integrations/datadog/package.json new file mode 100644 index 00000000..e3e401a2 --- /dev/null +++ b/integrations/datadog/package.json @@ -0,0 +1,50 @@ +{ + "name": "@core/datadog", + "version": "0.1.0", + "description": "Datadog extension for CORE", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "datadog", + "bin" + ], + "bin": { + "datadog": "./bin/index.js" + }, + "scripts": { + "build": "rimraf dist && bun build src/index.ts --outfile dist/index.js --target node --minify", + "lint": "eslint --ext js,ts,tsx src/ --fix", + "prettier": "prettier --config .prettierrc --write .", + "test": "bun test" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@types/node": "^18.0.20", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^3.4.2", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "typescript": "^4.7.2" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "axios": "^1.7.9", + "commander": "^12.0.0", + "@redplanethq/sdk": "0.1.9", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.1" + } +} diff --git a/integrations/datadog/scripts/register.ts b/integrations/datadog/scripts/register.ts new file mode 100644 index 00000000..1d07a3ed --- /dev/null +++ b/integrations/datadog/scripts/register.ts @@ -0,0 +1,85 @@ +import pg from 'pg'; + +const { Client } = pg; + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const client = new Client({ connectionString }); + + const spec = { + name: 'Datadog', + key: 'datadog', + description: + 'Connect Datadog to CORE to surface monitor alerts and infrastructure events as activities.', + icon: 'datadog', + schedule: { + frequency: '*/15 * * * *', + }, + auth: { + api_key: { + fields: [ + { + name: 'api_key', + label: 'API Key (DD-API-KEY)', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: + 'Your Datadog API key. Found in Datadog → Organization Settings → API Keys.', + }, + { + name: 'app_key', + label: 'Application Key (DD-APPLICATION-KEY)', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: + 'Your Datadog Application key. Found in Datadog → Organization Settings → Application Keys.', + }, + { + name: 'region', + label: 'Region', + placeholder: 'US1', + description: + 'Your Datadog region. One of: US1, US3, US5, EU, AP1. Defaults to US1.', + }, + ], + }, + }, + }; + + try { + await client.connect(); + + await client.query( + ` + INSERT INTO core."IntegrationDefinitionV2" ("id", "name", "slug", "description", "icon", "spec", "config", "version", "url", "updatedAt", "createdAt") + VALUES (gen_random_uuid(), 'Datadog', 'datadog', 'Connect Datadog to CORE to surface monitor alerts and infrastructure events as activities.', 'datadog', $1, $2, '0.1.0', $3, NOW(), NOW()) + ON CONFLICT (name) DO UPDATE SET + "slug" = EXCLUDED."slug", + "description" = EXCLUDED."description", + "icon" = EXCLUDED."icon", + "spec" = EXCLUDED."spec", + "config" = EXCLUDED."config", + "version" = EXCLUDED."version", + "url" = EXCLUDED."url", + "updatedAt" = NOW() + RETURNING *; + `, + [ + JSON.stringify(spec), + JSON.stringify({}), + '../../integrations/datadog/dist/index.js', + ], + ); + + console.log('Datadog integration registered successfully in the database.'); + } catch (error) { + console.error('Error registering Datadog integration:', error); + } finally { + await client.end(); + } +} + +main().catch(console.error); diff --git a/integrations/datadog/src/account-create.ts b/integrations/datadog/src/account-create.ts new file mode 100644 index 00000000..e7bceee3 --- /dev/null +++ b/integrations/datadog/src/account-create.ts @@ -0,0 +1,51 @@ +import { createDatadogClient, getBaseUrl } from './utils'; + +export async function integrationCreate(data: Record) { + const { api_key, app_key, region = 'US1' } = data; + + if (!api_key || !app_key) { + throw new Error('DD-API-KEY and DD-APPLICATION-KEY are required'); + } + + const client = createDatadogClient(api_key, app_key, region); + + // Validate credentials via /api/v1/validate + const validateRes = await client.get('/api/v1/validate'); + if (!validateRes.data?.valid) { + throw new Error('Invalid Datadog API key — /api/v1/validate returned invalid'); + } + + // Fetch org info for display name and account ID + let orgName = 'Datadog'; + let orgPublicId = 'unknown'; + try { + const orgRes = await client.get('/api/v1/org'); + const org = orgRes.data?.org ?? orgRes.data?.orgs?.[0]; + orgName = org?.name ?? orgName; + orgPublicId = org?.public_id ?? orgPublicId; + } catch { + // non-fatal: org info is cosmetic + } + + const baseUrl = getBaseUrl(region); + + return [ + { + type: 'account', + data: { + settings: { + orgName, + orgPublicId, + region, + baseUrl, + }, + accountId: orgPublicId, + config: { + api_key, + app_key, + region, + }, + }, + }, + ]; +} diff --git a/integrations/datadog/src/create-activity.ts b/integrations/datadog/src/create-activity.ts new file mode 100644 index 00000000..6a00d285 --- /dev/null +++ b/integrations/datadog/src/create-activity.ts @@ -0,0 +1,65 @@ +import { DatadogEvent, DatadogMonitor } from './utils'; + +function monitorStateLabel(state: string): string { + switch (state.toLowerCase()) { + case 'alert': + return 'ALERT'; + case 'warn': + return 'WARN'; + case 'no data': + case 'no_data': + return 'NO DATA'; + case 'ok': + return 'OK'; + case 'ignored': + return 'IGNORED'; + case 'skipped': + return 'SKIPPED'; + default: + return state.toUpperCase(); + } +} + +export function createActivityFromMonitor( + monitor: DatadogMonitor, + baseUrl: string, +): { type: string; data: { text: string; sourceURL: string } } | null { + // Only surface non-OK monitors as activities + const state = (monitor.overall_state || monitor.status || '').toLowerCase(); + if (state === 'ok' || state === 'ignored' || state === 'skipped') { + return null; + } + + const label = monitorStateLabel(monitor.overall_state || monitor.status); + const monitorUrl = `${baseUrl}/monitors/${monitor.id}`; + const tagsStr = monitor.tags?.length ? ` [${monitor.tags.join(', ')}]` : ''; + + return { + type: 'activity', + data: { + text: `[${label}] Monitor "${monitor.name}"${tagsStr}`, + sourceURL: monitorUrl, + }, + }; +} + +export function createActivityFromEvent( + event: DatadogEvent, +): { type: string; data: { text: string; sourceURL: string } } | null { + if (!event.title && !event.text) { + return null; + } + + const alertType = event.alert_type ? ` [${event.alert_type.toUpperCase()}]` : ''; + const host = event.host ? ` on ${event.host}` : ''; + const title = event.title || event.text.slice(0, 120); + const text = `${alertType} ${title}${host}`.trim(); + + return { + type: 'activity', + data: { + text, + sourceURL: event.url || '', + }, + }; +} diff --git a/integrations/datadog/src/index.ts b/integrations/datadog/src/index.ts new file mode 100644 index 00000000..af877a7f --- /dev/null +++ b/integrations/datadog/src/index.ts @@ -0,0 +1,83 @@ +import { fileURLToPath } from 'url'; + +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; + +import { integrationCreate } from './account-create'; +import { handleSchedule } from './schedule'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.SYNC: + return await handleSchedule(eventPayload.config as Record, eventPayload.state); + + default: + return { message: `The event payload type is ${eventPayload.event}` }; + } +} + +class DatadogCLI extends IntegrationCLI { + constructor() { + super('datadog', '1.0.0'); + } + + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Datadog', + key: 'datadog', + description: + 'Connect Datadog to CORE to surface monitor alerts and infrastructure events as activities.', + icon: 'datadog', + schedule: { + frequency: '*/15 * * * *', + }, + auth: { + api_key: { + fields: [ + { + name: 'api_key', + label: 'API Key (DD-API-KEY)', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: + 'Your Datadog API key. Found in Datadog → Organization Settings → API Keys.', + }, + { + name: 'app_key', + label: 'Application Key (DD-APPLICATION-KEY)', + placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: + 'Your Datadog Application key. Found in Datadog → Organization Settings → Application Keys.', + }, + { + name: 'region', + label: 'Region', + placeholder: 'US1', + description: + 'Your Datadog region. One of: US1, US3, US5, EU, AP1. Defaults to US1.', + }, + ], + }, + }, + }; + } +} + +function main() { + const datadogCLI = new DatadogCLI(); + datadogCLI.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/datadog/src/schedule.ts b/integrations/datadog/src/schedule.ts new file mode 100644 index 00000000..26fb812c --- /dev/null +++ b/integrations/datadog/src/schedule.ts @@ -0,0 +1,71 @@ +import { createActivityFromEvent, createActivityFromMonitor } from './create-activity'; +import { + createDatadogClient, + extractConfig, + fetchAllEventsSince, + fetchMonitors, + getBaseUrl, +} from './utils'; + +interface DatadogState { + lastEventTimestamp?: number; + lastMonitorSync?: string; +} + +const DEFAULT_LOOKBACK_SECONDS = 24 * 60 * 60; // 24 hours + +export async function handleSchedule(config: Record, state: unknown) { + const { api_key, app_key, region } = extractConfig(config); + + if (!api_key || !app_key) { + return []; + } + + const settings = (state || {}) as DatadogState; + const now = Math.floor(Date.now() / 1000); + const lastEventTimestamp = settings.lastEventTimestamp ?? now - DEFAULT_LOOKBACK_SECONDS; + + const client = createDatadogClient(api_key, app_key, region); + const baseUrl = getBaseUrl(region); + + const messages: Array<{ type: string; data: unknown }> = []; + + // --- Monitors: surface non-OK monitors --- + try { + const monitors = await fetchMonitors(client); + for (const monitor of monitors) { + const activity = createActivityFromMonitor(monitor, baseUrl); + if (activity) { + messages.push(activity); + } + } + } catch { + // Non-fatal; continue to events + } + + // --- Events: incremental sync since last run --- + try { + const events = await fetchAllEventsSince(client, lastEventTimestamp); + // Events come newest-first from Datadog; process them all + for (const event of events) { + const activity = createActivityFromEvent(event); + if (activity) { + messages.push(activity); + } + } + } catch { + // Non-fatal + } + + // Persist state + messages.push({ + type: 'state', + data: { + ...settings, + lastEventTimestamp: now, + lastMonitorSync: new Date().toISOString(), + }, + }); + + return messages; +} diff --git a/integrations/datadog/src/utils.ts b/integrations/datadog/src/utils.ts new file mode 100644 index 00000000..894f8d25 --- /dev/null +++ b/integrations/datadog/src/utils.ts @@ -0,0 +1,125 @@ +import axios, { AxiosInstance } from 'axios'; + +export type DatadogRegion = 'US1' | 'US3' | 'US5' | 'EU' | 'AP1'; + +const REGION_BASE_URLS: Record = { + US1: 'https://api.datadoghq.com', + US3: 'https://us3.datadoghq.com', + US5: 'https://us5.datadoghq.com', + EU: 'https://api.datadoghq.eu', + AP1: 'https://ap1.datadoghq.com', +}; + +export function getBaseUrl(region: string): string { + const r = (region || 'US1').toUpperCase() as DatadogRegion; + return REGION_BASE_URLS[r] ?? REGION_BASE_URLS['US1']; +} + +export function createDatadogClient(apiKey: string, appKey: string, region: string): AxiosInstance { + return axios.create({ + baseURL: getBaseUrl(region), + headers: { + 'DD-API-KEY': apiKey, + 'DD-APPLICATION-KEY': appKey, + 'Content-Type': 'application/json', + }, + }); +} + +export interface DatadogConfig { + api_key: string; + app_key: string; + region: string; +} + +export function extractConfig(config: Record): DatadogConfig { + return { + api_key: config['api_key'] as string, + app_key: config['app_key'] as string, + region: (config['region'] as string) || 'US1', + }; +} + +export interface DatadogMonitor { + id: number; + name: string; + type: string; + status: string; + overall_state: string; + message: string; + modified: string; + created: string; + tags: string[]; + query: string; +} + +export interface DatadogEvent { + id: number; + title: string; + text: string; + date_happened: number; + priority: string; + source: string; + tags: string[]; + url: string; + alert_type?: string; + host?: string; +} + +/** + * Fetch monitors with pagination + */ +export async function fetchMonitors( + client: AxiosInstance, + page = 0, + pageSize = 100, +): Promise { + const response = await client.get('/api/v1/monitor', { + params: { page, page_size: pageSize }, + }); + return response.data as DatadogMonitor[]; +} + +/** + * Fetch events between start and end (Unix timestamps) + * Returns events and whether there may be more pages. + */ +export async function fetchEvents( + client: AxiosInstance, + start: number, + end: number, + page = 0, +): Promise<{ events: DatadogEvent[]; hasMore: boolean }> { + const response = await client.get('/api/v1/events', { + params: { start, end, count: 100, page }, + }); + const events: DatadogEvent[] = response.data?.events ?? []; + // Datadog returns up to 100 events per page; if we got a full page, there may be more + return { events, hasMore: events.length === 100 }; +} + +/** + * Fetch all events since lastTimestamp using pagination. + */ +export async function fetchAllEventsSince( + client: AxiosInstance, + lastTimestamp: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + const allEvents: DatadogEvent[] = []; + let page = 0; + let hasMore = true; + + while (hasMore) { + try { + const result = await fetchEvents(client, lastTimestamp, now, page); + allEvents.push(...result.events); + hasMore = result.hasMore; + page += 1; + } catch { + hasMore = false; + } + } + + return allEvents; +} diff --git a/integrations/datadog/tsconfig.json b/integrations/datadog/tsconfig.json new file mode 100644 index 00000000..ea7773e4 --- /dev/null +++ b/integrations/datadog/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "useUnknownInCatchVariables": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "dist", "scripts"] +}