Skip to content

Commit d6e2772

Browse files
committed
Collect Datadog integration metadata
1 parent 590f67c commit d6e2772

8 files changed

Lines changed: 109 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Added validation and export path-safety checks that reject symlinks in evidence bundle inputs.
2525
- Added best-effort live GitHub App installation metadata collection for the GitHub Enterprise to Forgejo path.
2626
- Added referenced metric and tag metadata capture to the live Datadog collector.
27+
- Added best-effort live Datadog integration installation metadata collection.
2728
- Updated CI and release workflows to Node.js 24-native GitHub Actions.
2829

2930
## 0.1.0 - 2026-05-24

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Included in the current implementation:
9494
- CLI skeleton and project init/status.
9595
- Typed project, inventory, assessment, mapping, and validation manifests.
9696
- Fixture-based Datadog inventory import.
97-
- Read-only Datadog collection for dashboards, monitors, SLOs, and referenced metric/tag metadata.
97+
- Read-only Datadog collection for dashboards, monitors, SLOs, installed integration metadata, and referenced metric/tag metadata.
9898
- Read-only GitHub/GitHub Enterprise collection for repositories, teams, branch protection, Actions workflows, secret metadata, runners, deploy keys, and GitHub App installations.
9999
- Read-only Okta collection for applications, groups, policy/rule metadata, org MFA factors, and explicit break-glass user metadata.
100100
- Read-only Auth0 collection for clients, roles, action/rule metadata, Guardian MFA factors, and explicit break-glass user metadata.

docs/datadog-to-grafana.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Validation checks the ArgoCD candidate for Application shape, OpenExit candidate
2929

3030
## Live Collection
3131

32-
The live Datadog collector stores redacted evidence for dashboards, monitors, and SLOs. It records referenced metric names and tag keys from dashboard and monitor queries so cost, cardinality, and target sizing review can use the same metadata as fixture-based assessments. Evidence refs in generated assessments resolve to local files under `evidence/datadog/`.
32+
The live Datadog collector stores redacted evidence for dashboards, monitors, SLOs, and integration installation metadata where the Datadog API exposes it. It records referenced metric names and tag keys from dashboard and monitor queries so cost, cardinality, and target sizing review can use the same metadata as fixture-based assessments. Evidence refs in generated assessments resolve to local files under `evidence/datadog/`.
3333

3434
## Risk Rules
3535

docs/schemas.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ OpenExit schemas live under `schemas/` and mirror the typed Go manifests. Releas
44

55
Project manifests must use one of the supported source/target pairs: Datadog to Grafana LGTM, GitHub Enterprise to Forgejo, Okta/Auth0 to Keycloak/Zitadel, Cloudflare/Akamai to Varnish/HAProxy/Coraza, or OpenAI/Anthropic to vLLM/LiteLLM.
66

7-
Inventory dashboards can include optional `dataSources` and `templateVariables` fields so assessment can flag Grafana mapping risk. Datadog fixture and live collectors populate `metrics` from captured dashboard and monitor queries, including referenced tag keys where available. SLOs can include optional `sli`, `burnRateMonitorIds`, and `dashboardRefs` fields. The top-level inventory `volumes` section records whether log and trace volume assumptions are known.
7+
Inventory dashboards can include optional `dataSources` and `templateVariables` fields so assessment can flag Grafana mapping risk. Datadog fixture and live collectors populate `metrics` from captured dashboard and monitor queries, including referenced tag keys where available. The live Datadog collector also populates `integrations` from the Datadog v2 Integrations API when accessible. SLOs can include optional `sli`, `burnRateMonitorIds`, and `dashboardRefs` fields. The top-level inventory `volumes` section records whether log and trace volume assumptions are known.
88

99
Mapping manifests use `kind: Mapping` and are written to `mapping/openexit.mapping.yaml` and `.json`. They record candidate dashboard paths, alert-rule candidate paths, unsupported source items, and manual-review entries derived from assessment findings. Validation reloads the mapping manifest and checks that source and target types still match inventory and assessment.
1010

docs/security.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ OpenExit is designed for local-first assessment work.
44

55
- Collectors must not make production writes.
66
- Live Datadog, GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic credentials are read from environment variables or local credential files and are never written to project files.
7+
- The Datadog collector records dashboard, monitor, SLO, integration installation, metric, and tag metadata only; it never mutates Datadog resources.
78
- The GitHub collector records repository, workflow, runner, deploy-key, GitHub App installation, and secret metadata only; it never requests or stores secret values.
89
- The Okta collector records client and policy metadata only; it never requests or stores client secrets, passwords, factor secrets, or token values.
910
- The Auth0 collector records client, role, action/rule, Guardian MFA, and explicit break-glass user metadata only; it does not persist client secrets, action code, rule scripts, user passwords, MFA secrets, or token values.

internal/collector/datadog/live.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ func (LiveCollector) Collect(ctx context.Context, req collector.CollectRequest)
4444
if err := collectSLOs(ctx, client, req.ProjectDir, inv); err != nil {
4545
warnings = append(warnings, err.Error())
4646
}
47+
if err := collectIntegrations(ctx, client, req.ProjectDir, inv); err != nil {
48+
warnings = append(warnings, err.Error())
49+
}
4750
inv.Warnings = warnings
4851
inv.RecomputeSummary()
4952
if err := inventory.Validate(inv); err != nil {
@@ -242,6 +245,44 @@ func collectSLOs(ctx context.Context, client *Client, projectDir string, inv *in
242245
return nil
243246
}
244247

248+
func collectIntegrations(ctx context.Context, client *Client, projectDir string, inv *inventory.Inventory) error {
249+
var out struct {
250+
Data []struct {
251+
ID string `json:"id"`
252+
Attributes struct {
253+
Title string `json:"title"`
254+
Categories []string `json:"categories"`
255+
Installed bool `json:"installed"`
256+
} `json:"attributes"`
257+
} `json:"data"`
258+
}
259+
body, err := client.get(ctx, "/api/v2/integrations", nil, &out)
260+
if err != nil {
261+
return fmt.Errorf("collect integrations: %w", err)
262+
}
263+
if err := writeEvidence(projectDir, "datadog/integrations/list.json", inventory.RedactBytes(body)); err != nil {
264+
return err
265+
}
266+
for _, item := range out.Data {
267+
name := firstNonEmpty(item.Attributes.Title, item.ID)
268+
if name == "" {
269+
continue
270+
}
271+
integration := inventory.Integration{
272+
Name: name,
273+
Enabled: item.Attributes.Installed,
274+
Tags: uniqueSorted(item.Attributes.Categories),
275+
EvidenceRef: "evidence://datadog/integration/" + safeID(name),
276+
}
277+
if err := writeEvidence(projectDir, "datadog/integrations/"+safeID(name)+".json", inventory.RedactBytes(prettyJSON(item))); err != nil {
278+
return err
279+
}
280+
inv.Assets.Integrations = append(inv.Assets.Integrations, integration)
281+
}
282+
sort.Slice(inv.Assets.Integrations, func(i, j int) bool { return inv.Assets.Integrations[i].Name < inv.Assets.Integrations[j].Name })
283+
return nil
284+
}
285+
245286
func stringSlice(value any) []string {
246287
raw, ok := value.([]any)
247288
if !ok {
@@ -261,6 +302,16 @@ func rawString(value any) string {
261302
return text
262303
}
263304

305+
func firstNonEmpty(values ...string) string {
306+
for _, value := range values {
307+
value = strings.TrimSpace(value)
308+
if value != "" {
309+
return value
310+
}
311+
}
312+
return ""
313+
}
314+
264315
func uniqueSorted(values []string) []string {
265316
seen := map[string]struct{}{}
266317
for _, value := range values {

internal/collector/datadog/live_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http/httptest"
77
"os"
88
"path/filepath"
9+
"strings"
910
"testing"
1011
"time"
1112

@@ -44,6 +45,11 @@ func TestLiveCollectorNormalizationWithMockedAPI(t *testing.T) {
4445
}]`))
4546
case "/api/v1/slo":
4647
_, _ = w.Write([]byte(`{"data":[{"id":"slo-1","attributes":{"name":"Availability","target_threshold":99.9,"timeframe":"30d"}}]}`))
48+
case "/api/v2/integrations":
49+
_, _ = w.Write([]byte(`{"data":[
50+
{"id":"kubernetes","type":"integration","attributes":{"title":"Kubernetes","categories":["containers","orchestration"],"installed":true}},
51+
{"id":"pagerduty","type":"integration","attributes":{"title":"PagerDuty","categories":["alerting"],"installed":false}}
52+
]}`))
4753
default:
4854
http.NotFound(w, r)
4955
}
@@ -61,6 +67,9 @@ func TestLiveCollectorNormalizationWithMockedAPI(t *testing.T) {
6167
if err := collectSLOs(context.Background(), client, projectDir, inv); err != nil {
6268
t.Fatal(err)
6369
}
70+
if err := collectIntegrations(context.Background(), client, projectDir, inv); err != nil {
71+
t.Fatal(err)
72+
}
6473
inv.RecomputeSummary()
6574
if err := inventory.Validate(inv); err != nil {
6675
t.Fatal(err)
@@ -71,9 +80,18 @@ func TestLiveCollectorNormalizationWithMockedAPI(t *testing.T) {
7180
if inv.Summary.Dashboards != 1 || inv.Summary.Monitors != 1 || inv.Summary.SLOs != 1 {
7281
t.Fatalf("unexpected summary: %+v", inv.Summary)
7382
}
83+
if inv.Summary.Integrations != 2 {
84+
t.Fatalf("expected live integrations in summary, got %+v", inv.Summary)
85+
}
7486
if inv.Summary.UniqueMetrics != 2 {
7587
t.Fatalf("expected live queries to populate metric summary, got %+v", inv.Summary)
7688
}
89+
if integration := integrationByName(inv.Assets.Integrations, "Kubernetes"); integration == nil || !integration.Enabled || !hasAll(integration.Tags, "containers", "orchestration") {
90+
t.Fatalf("expected installed Kubernetes integration metadata, got %+v", inv.Assets.Integrations)
91+
}
92+
if integration := integrationByName(inv.Assets.Integrations, "PagerDuty"); integration == nil || integration.Enabled {
93+
t.Fatalf("expected disabled PagerDuty integration metadata, got %+v", inv.Assets.Integrations)
94+
}
7795
if metric := metricByName(inv.Assets.Metrics, "system.cpu.user"); metric == nil || !hasAll(metric.Tags, "env", "service") {
7896
t.Fatalf("expected dashboard metric tags to be captured, got %+v", inv.Assets.Metrics)
7997
}
@@ -84,6 +102,7 @@ func TestLiveCollectorNormalizationWithMockedAPI(t *testing.T) {
84102
inv.Assets.Dashboards[0].EvidenceRef,
85103
inv.Assets.Monitors[0].EvidenceRef,
86104
inv.Assets.SLOs[0].EvidenceRef,
105+
inv.Assets.Integrations[0].EvidenceRef,
87106
} {
88107
path, err := evidence.PathForRef(projectDir, ref)
89108
if err != nil {
@@ -103,6 +122,29 @@ func TestLiveCollectorNormalizationWithMockedAPI(t *testing.T) {
103122
}
104123
}
105124

125+
func TestCollectIntegrationsReturnsWarningFriendlyError(t *testing.T) {
126+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127+
w.Header().Set("Content-Type", "application/json")
128+
if r.URL.Path == "/api/v2/integrations" {
129+
w.WriteHeader(http.StatusForbidden)
130+
_, _ = w.Write([]byte(`{"errors":["missing integrations_read"]}`))
131+
return
132+
}
133+
http.NotFound(w, r)
134+
}))
135+
defer server.Close()
136+
137+
client := &Client{baseURL: server.URL, apiKey: "api", appKey: "app", http: server.Client()}
138+
inv := inventory.New("demo", "datadog", "datadoghq.eu", "test", time.Unix(0, 0))
139+
err := collectIntegrations(context.Background(), client, t.TempDir(), inv)
140+
if err == nil || !strings.Contains(err.Error(), "collect integrations") {
141+
t.Fatalf("expected contextual integration collection error, got %v", err)
142+
}
143+
if len(inv.Assets.Integrations) != 0 {
144+
t.Fatalf("expected no integration assets on failed collection, got %+v", inv.Assets.Integrations)
145+
}
146+
}
147+
106148
func TestDatadogBaseURLValidation(t *testing.T) {
107149
for _, site := range []string{"datadoghq.com", "datadoghq.eu", "us3.datadoghq.com", "us5.datadoghq.com", "ap1.datadoghq.com", "ap2.datadoghq.com"} {
108150
if _, err := datadogBaseURL(site); err != nil {
@@ -126,6 +168,15 @@ func metricByName(metrics []inventory.MetricRef, name string) *inventory.MetricR
126168
return nil
127169
}
128170

171+
func integrationByName(integrations []inventory.Integration, name string) *inventory.Integration {
172+
for i := range integrations {
173+
if integrations[i].Name == name {
174+
return &integrations[i]
175+
}
176+
}
177+
return nil
178+
}
179+
129180
func hasAll(values []string, expected ...string) bool {
130181
seen := map[string]struct{}{}
131182
for _, value := range values {

internal/evidence/refs.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func PathForRef(projectDir, ref string) (string, error) {
2525
dir = "monitors"
2626
case "slo":
2727
dir = "slos"
28+
case "integration":
29+
dir = "integrations"
2830
case "repository":
2931
dir = "repositories"
3032
case "team":

0 commit comments

Comments
 (0)