From 8d07d420308a104edd8750ea19342f6dbccdfb0c Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Thu, 7 May 2026 16:48:10 +0900 Subject: [PATCH 1/2] Add Datadog integration package using official API client Introduce internal/integration/datadog with ListMonitors (paginated) and monitor app URL helper; wire go.mod to datadog-api-client-go/v2. Parent integration package doc clarifies vendor subpackage layout. Co-authored-by: Cursor --- go.mod | 12 +- go.sum | 30 ++++ internal/integration/datadog/client.go | 154 ++++++++++++++++++++ internal/integration/datadog/client_test.go | 56 +++++++ internal/integration/datadog/doc.go | 3 + internal/integration/datadog/types.go | 8 + internal/integration/datadog/url.go | 13 ++ internal/integration/doc.go | 5 +- 8 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 internal/integration/datadog/client.go create mode 100644 internal/integration/datadog/client_test.go create mode 100644 internal/integration/datadog/doc.go create mode 100644 internal/integration/datadog/types.go create mode 100644 internal/integration/datadog/url.go diff --git a/go.mod b/go.mod index 807354e..b7b000a 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,18 @@ module github.com/keyskey/hado go 1.22 require ( + github.com/DataDog/datadog-api-client-go/v2 v2.54.0 golang.org/x/term v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.28.0 // indirect +require ( + github.com/DataDog/zstd v1.5.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum index 7c925dc..f245a5d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,37 @@ +github.com/DataDog/datadog-api-client-go/v2 v2.54.0 h1:bLSwX1D7JA7hAHxpo8Aa2+d8F2wzD8sNOJszL89yGyU= +github.com/DataDog/datadog-api-client-go/v2 v2.54.0/go.mod h1:d3tOEgUd2kfsr9uuHQdY+nXrWp4uikgTgVCPdKNK30U= +github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= +github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/integration/datadog/client.go b/internal/integration/datadog/client.go new file mode 100644 index 0000000..2453e56 --- /dev/null +++ b/internal/integration/datadog/client.go @@ -0,0 +1,154 @@ +package datadog + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + dd "github.com/DataDog/datadog-api-client-go/v2/api/datadog" + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" +) + +const defaultSite = "datadoghq.com" + +// Config holds API authentication and site. Keys are never read from disk by this package. +type Config struct { + APIKey string + AppKey string + Site string + // APIBaseURL, if non-empty, is a full base URL (e.g. httptest server) used to override + // the API host via Configuration.Scheme/Host (for tests). + APIBaseURL string +} + +// Client lists Datadog monitors via the official datadog-api-client-go. +type Client struct { + cfg Config + site string + monitors *datadogV1.MonitorsApi +} + +// NewClient returns a Client with the given config and HTTP client. +func NewClient(cfg Config, httpClient *http.Client) (*Client, error) { + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, fmt.Errorf("datadog: api key is empty") + } + if strings.TrimSpace(cfg.AppKey) == "" { + return nil, fmt.Errorf("datadog: application key is empty") + } + site := strings.TrimSpace(cfg.Site) + if site == "" { + site = defaultSite + } + + configuration := dd.NewConfiguration() + if httpClient != nil { + configuration.HTTPClient = httpClient + } + if base := strings.TrimSpace(cfg.APIBaseURL); base != "" { + parsed, err := url.Parse(base) + if err != nil { + return nil, fmt.Errorf("datadog: parse api base url: %w", err) + } + if parsed.Host != "" { + if parsed.Scheme != "" { + configuration.Scheme = parsed.Scheme + } else { + configuration.Scheme = "http" + } + configuration.Host = parsed.Host + } + } + + apiClient := dd.NewAPIClient(configuration) + return &Client{ + cfg: Config{ + APIKey: strings.TrimSpace(cfg.APIKey), + AppKey: strings.TrimSpace(cfg.AppKey), + Site: site, + APIBaseURL: strings.TrimSpace(cfg.APIBaseURL), + }, + site: site, + monitors: datadogV1.NewMonitorsApi(apiClient), + }, nil +} + +// NewClientFromEnv builds a client using DD_API_KEY, DD_APP_KEY, and optional DD_SITE. +func NewClientFromEnv(httpClient *http.Client) (*Client, error) { + return NewClient(Config{ + APIKey: os.Getenv("DD_API_KEY"), + AppKey: os.Getenv("DD_APP_KEY"), + Site: os.Getenv("DD_SITE"), + }, httpClient) +} + +// ListMonitors returns all monitors; call filters the result. Pagination is handled by the generated client. +func (c *Client) ListMonitors(ctx context.Context) ([]Monitor, error) { + ctx = c.apiContext(ctx) + ch, cancel := c.monitors.ListMonitorsWithPagination(ctx) + defer cancel() + + var v1mons []datadogV1.Monitor + for result := range ch { + if result.Error != nil { + return nil, formatListMonitorsError(result.Error) + } + v1mons = append(v1mons, result.Item) + } + + out := make([]Monitor, 0, len(v1mons)) + for _, m := range v1mons { + if m.Id == nil { + continue + } + name := "" + if m.Name != nil { + name = *m.Name + } + tags := m.Tags + if tags == nil { + tags = []string{} + } + out = append(out, Monitor{ + ID: *m.Id, + Name: name, + Tags: tags, + }) + } + return out, nil +} + +// Site returns the normalized Datadog site suffix (for example datadoghq.com). +func (c *Client) Site() string { + return c.cfg.Site +} + +func (c *Client) apiContext(ctx context.Context) context.Context { + ctx = context.WithValue(ctx, dd.ContextAPIKeys, map[string]dd.APIKey{ + "apiKeyAuth": {Key: c.cfg.APIKey}, + "appKeyAuth": {Key: c.cfg.AppKey}, + }) + // Test hosts override URL host via Configuration; default servers still need DD_SITE. + if strings.TrimSpace(c.cfg.APIBaseURL) == "" { + ctx = context.WithValue(ctx, dd.ContextServerVariables, map[string]string{ + "site": c.site, + }) + } + return ctx +} + +func formatListMonitorsError(err error) error { + var openAPIErr dd.GenericOpenAPIError + if errors.As(err, &openAPIErr) { + msg := strings.TrimSpace(string(openAPIErr.Body())) + if msg == "" { + return fmt.Errorf("datadog: list monitors: %s", openAPIErr.Error()) + } + return fmt.Errorf("datadog: list monitors: %s: %s", openAPIErr.Error(), msg) + } + return fmt.Errorf("datadog: list monitors: %w", err) +} diff --git a/internal/integration/datadog/client_test.go b/internal/integration/datadog/client_test.go new file mode 100644 index 0000000..3dbfbf1 --- /dev/null +++ b/internal/integration/datadog/client_test.go @@ -0,0 +1,56 @@ +package datadog + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListMonitors_decode(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/monitor" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.Header.Get("DD-API-KEY") != "api" || r.Header.Get("DD-APPLICATION-KEY") != "app" { + t.Fatalf("missing auth headers") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":42,"name":"test","type":"query alert","query":"","tags":["env:prod","hado:foo"]}]`)) + })) + defer ts.Close() + + c, err := NewClient(Config{ + APIKey: "api", + AppKey: "app", + Site: "datadoghq.com", + APIBaseURL: ts.URL, + }, ts.Client()) + if err != nil { + t.Fatal(err) + } + + mons, err := c.ListMonitors(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(mons) != 1 || mons[0].ID != 42 || mons[0].Name != "test" { + t.Fatalf("got %+v", mons) + } +} + +func TestMonitorAppURL_defaultSite(t *testing.T) { + got := MonitorAppURL("", 7) + want := "https://app.datadoghq.com/monitors/7" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestMonitorAppURL_eu(t *testing.T) { + got := MonitorAppURL("datadoghq.eu", 9) + want := "https://app.datadoghq.eu/monitors/9" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/internal/integration/datadog/doc.go b/internal/integration/datadog/doc.go new file mode 100644 index 0000000..18982b5 --- /dev/null +++ b/internal/integration/datadog/doc.go @@ -0,0 +1,3 @@ +// Package datadog wraps the official Datadog HTTP API client for monitor listing and URL helpers. +// It does not import manifest types or gate IDs; callers own discovery rules and manifest updates. +package datadog diff --git a/internal/integration/datadog/types.go b/internal/integration/datadog/types.go new file mode 100644 index 0000000..1e350ed --- /dev/null +++ b/internal/integration/datadog/types.go @@ -0,0 +1,8 @@ +package datadog + +// Monitor is a subset of Datadog monitor API fields needed for discovery and linking. +type Monitor struct { + ID int64 `json:"id"` + Name string `json:"name"` + Tags []string `json:"tags"` +} diff --git a/internal/integration/datadog/url.go b/internal/integration/datadog/url.go new file mode 100644 index 0000000..c1a74b7 --- /dev/null +++ b/internal/integration/datadog/url.go @@ -0,0 +1,13 @@ +package datadog + +import "fmt" + +// MonitorAppURL returns the web UI URL for a monitor id. site is the Datadog site suffix +// (for example datadoghq.com, datadoghq.eu) matching DD_SITE. +func MonitorAppURL(site string, monitorID int64) string { + s := site + if s == "" { + s = defaultSite + } + return fmt.Sprintf("https://app.%s/monitors/%d", s, monitorID) +} diff --git a/internal/integration/doc.go b/internal/integration/doc.go index 55357a8..25205a4 100644 --- a/internal/integration/doc.go +++ b/internal/integration/doc.go @@ -1,3 +1,4 @@ -// Package integration defines contracts and shared plumbing for external -// system integrations (for example observability readiness and Datadog). +// Package integration holds thin HTTP/API clients for external systems. +// Vendor-specific code lives in subpackages (for example datadog). +// HADO manifest schemas and gate IDs must not be imported from here. package integration From dc68d4c2b1942b88f3d3abdc4e35a52208ae91cf Mon Sep 17 00:00:00 2001 From: keyskey <39644776+keyskey@users.noreply.github.com> Date: Thu, 7 May 2026 16:59:54 +0900 Subject: [PATCH 2/2] docs(architecture): show internal/integration/datadog in repo tree Co-authored-by: Cursor --- docs/architecture.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 59a171b..3017371 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,8 @@ HADO 本体リポジトリは、root に `go.mod` を置く単一 Go module と │ ├── standard # Readiness Standard の読み込みと検証 │ ├── coverage # coverage artifact の adapter と正規化 metric │ ├── gate # gate 評価と最終 decision -│ └── integration # 外部システム連携の契約・基盤(予定: observability / Datadog など) +│ ├── integration # 外部 SaaS API の薄いラッパ。manifest や gate ID を import しない +│ │ └── datadog # Datadog Monitor 一覧など。公式 datadog-api-client-go を利用 │ ├── modules │ ├── github