Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions platform/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ NEXT_PUBLIC_DEFAULT_LOGO_URL=/logo.svg
# a one-shot script before changing it in production.
INTEGRATIONS_ENCRYPTION_KEY=

# Shared secret for the daily Datadog sync cron job. Vercel Cron sends this
# automatically as `Authorization: Bearer $CRON_SECRET` to the cron route;
# in local dev you can pass it via `x-cron-secret: <value>` to hit
# /api/cron/sync-integrations manually. Generate with:
# openssl rand -base64 32
# If empty in development, the cron route is unauthenticated (production
# deployments still reject unauthenticated requests).
CRON_SECRET=

# --- Resend (required for transactional emails) ---
# Get an API key at https://resend.com/api-keys
RESEND_API_KEY=re_...
Expand Down
182 changes: 176 additions & 6 deletions platform/lib/integrations/datadog/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/**
* Datadog API client for the integration's connect flow.
* Datadog API client for the DORA Metrics v2 read endpoints.
*
* Only the bits slice 2 needs land here: credential validation against
* the DORA Metrics v2 read endpoints. Slice 3 will extend this module
* with the actual sync calls (deployments / failures pagination).
*
* See docs/PLAN-datadog.md §9 for the request shape and probe findings.
* Covers credential validation (slice 2) and the list-with-filters
* fetchers used by the daily sync (slice 3). Pagination semantics are
* documented in docs/PLAN-datadog.md §9.5 — there is no cursor mechanism
* on these endpoints, the only way forward is to shrink the `to` window.
*/

export type DatadogSite =
Expand Down Expand Up @@ -134,3 +133,174 @@ function stripMillis(d: Date): string {
// sites. The Z form is the safest documented shape.
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}

export function toDatadogTimestamp(d: Date): string {
return stripMillis(d);
}

export interface DoraListFilters {
/** ISO 8601 (seconds precision, trailing Z). */
from: string;
/** ISO 8601 (seconds precision, trailing Z). */
to: string;
/** Datadog query language. Use "*" for unfiltered. */
query: string;
/**
* Page size. Probed ceiling is at least 500; we cap at 100 for v1 to
* keep response latency predictable. The §9.5 pagination loop relies
* on `len(events) < limit` as the terminal condition.
*/
limit: number;
}

export interface DoraDeploymentCommit {
sha: string;
timestamp?: string;
author?: {
email?: string;
canonical_email?: string;
is_bot?: boolean;
};
message?: string;
html_url?: string;
change_lead_time?: number;
time_to_deploy?: number;
}

export interface DoraDeployment {
type: "dora_deployment";
id: string;
attributes: {
git?: { commit_sha?: string; repository_id?: string };
commits?: DoraDeploymentCommit[];
pull_requests?: Array<{
created_at?: string;
merged_at?: string;
is_fully_automated?: boolean;
}>;
service?: string;
env?: string;
team?: string;
version?: string;
/** TRI-STATE: true | false | null (null = pending evaluation). */
change_failure?: boolean | null;
deployment_type?: string;
source?: string;
started_at: string;
finished_at?: string;
duration?: number;
created_at?: string;
number_of_commits?: number;
number_of_pull_requests?: number;
/** Present only when change_failure === true. */
recovery_time_sec?: number;
remediation?: { id?: string; type?: string };
};
}

export interface DoraFailure {
type: "dora_failure";
id: string;
attributes: {
service?: string[];
env?: string[];
team?: string[];
name?: string;
/** "Normal" | "High" | "Urgent" observed; free-form. */
severity?: string;
started_at: string;
finished_at?: string;
created_at?: string;
/** Seconds. */
time_to_restore?: number;
source?: string;
};
}

export class DatadogApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly detail?: string,
) {
super(message);
this.name = "DatadogApiError";
}
}

async function postDoraList<T>(
creds: DatadogCredentials,
endpoint: "deployments" | "failures",
requestType: "dora_deployments_list_request" | "dora_failures_list_request",
filters: DoraListFilters,
): Promise<T[]> {
const url = `https://api.${creds.site}/api/v2/dora/${endpoint}`;
const body = {
data: {
type: requestType,
attributes: {
from: filters.from,
to: filters.to,
query: filters.query,
limit: filters.limit,
},
},
};

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"DD-API-KEY": creds.apiKey,
"DD-APPLICATION-KEY": creds.appKey,
},
body: JSON.stringify(body),
// Each page fetch must complete inside the per-org sync budget.
signal: AbortSignal.timeout(30_000),
});

if (!response.ok) {
let detail: string | undefined;
try {
const payload = (await response.json()) as {
errors?: Array<{ detail?: string; title?: string }>;
};
const first = payload.errors?.[0];
detail = first?.detail ?? first?.title;
} catch {
detail = undefined;
}
throw new DatadogApiError(
`Datadog ${endpoint} list failed with HTTP ${response.status}`,
response.status,
detail,
);
}

const payload = (await response.json()) as { data?: T[] };
return payload.data ?? [];
}

export function listDeployments(
creds: DatadogCredentials,
filters: DoraListFilters,
): Promise<DoraDeployment[]> {
return postDoraList<DoraDeployment>(
creds,
"deployments",
"dora_deployments_list_request",
filters,
);
}

export function listFailures(
creds: DatadogCredentials,
filters: DoraListFilters,
): Promise<DoraFailure[]> {
return postDoraList<DoraFailure>(
creds,
"failures",
"dora_failures_list_request",
filters,
);
}
Loading
Loading