From 940f12b08c5cefc36ef349d85987e1913b28878c Mon Sep 17 00:00:00 2001 From: Scott Holodak Date: Fri, 31 Jul 2026 16:56:28 -0400 Subject: [PATCH 1/2] fix(datadog): rewrite billable-summary month param to the YYYY-MM the API requires The Datadog API now rejects /api/v1/usage/billable-summary requests whose month query parameter is a full RFC3339 timestamp, which is what the generated datadog-api-client-go emits for time.Time parameters (current client master included): 400 Bad Request: month must be in the format YYYY-MM Since GetDDUnitPrices has no fallback, this made every GetCustomCosts window fail, rendering the plugin unable to retrieve any costs. Add a small http.RoundTripper that rewrites the month parameter to YYYY-MM in transit, scoped to the billable-summary path. Doing it at the transport layer keeps the fix independent of the client library version and is a no-op if the client ever starts emitting YYYY-MM itself. Fixes #85 --- .../datadog/datadogplugin/transport.go | 45 ++++++++++++++++ .../datadog/datadogplugin/transport_test.go | 54 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 pkg/plugins/datadog/datadogplugin/transport.go create mode 100644 pkg/plugins/datadog/datadogplugin/transport_test.go diff --git a/pkg/plugins/datadog/datadogplugin/transport.go b/pkg/plugins/datadog/datadogplugin/transport.go new file mode 100644 index 0000000..9769ef2 --- /dev/null +++ b/pkg/plugins/datadog/datadogplugin/transport.go @@ -0,0 +1,45 @@ +package datadog + +import ( + "net/http" + "strings" + "time" +) + +// billableSummaryPath is the API path whose `month` query parameter requires +// YYYY-MM formatting. +const billableSummaryPath = "/api/v1/usage/billable-summary" + +// MonthParamRoundTripper rewrites the `month` query parameter on +// /api/v1/usage/billable-summary requests from the RFC3339 timestamp the +// generated datadog-api-client-go emits (e.g. 2024-03-01T00:00:00Z) to the +// YYYY-MM format the Datadog API requires. Without this rewrite the API +// rejects every request with: +// +// 400 Bad Request: {"errors":[{"status":"400","title":"Bad Request", +// "detail":"month must be in the format YYYY-MM"}]} +// +// which causes GetDDUnitPrices, and therefore every GetCustomCosts window, +// to fail. +type MonthParamRoundTripper struct { + // Next is the underlying RoundTripper to delegate to. If nil, + // http.DefaultTransport is used. + Next http.RoundTripper +} + +func (m MonthParamRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, billableSummaryPath) { + q := req.URL.Query() + if month := q.Get("month"); month != "" { + if t, err := time.Parse(time.RFC3339, month); err == nil { + q.Set("month", t.UTC().Format("2006-01")) + req.URL.RawQuery = q.Encode() + } + } + } + next := m.Next + if next == nil { + next = http.DefaultTransport + } + return next.RoundTrip(req) +} diff --git a/pkg/plugins/datadog/datadogplugin/transport_test.go b/pkg/plugins/datadog/datadogplugin/transport_test.go new file mode 100644 index 0000000..070cb42 --- /dev/null +++ b/pkg/plugins/datadog/datadogplugin/transport_test.go @@ -0,0 +1,54 @@ +package datadog + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func doRoundTrip(t *testing.T, path, rawQuery string) string { + t.Helper() + + var gotMonth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMonth = r.URL.Query().Get("month") + })) + defer server.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL+path+"?"+rawQuery, nil) + if err != nil { + t.Fatalf("building request: %v", err) + } + + client := &http.Client{Transport: MonthParamRoundTripper{}} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("round trip: %v", err) + } + resp.Body.Close() + + return gotMonth +} + +func TestMonthParamRewrittenOnBillableSummary(t *testing.T) { + got := doRoundTrip(t, "/api/v1/usage/billable-summary", "month=2024-03-01T00%3A00%3A00Z") + if got != "2024-03" { + t.Fatalf("expected month=2024-03, got month=%s", got) + } +} + +func TestMonthAlreadyShortFormPassedThrough(t *testing.T) { + // If the client library starts emitting YYYY-MM itself, the rewrite must + // be a no-op rather than corrupting the value. + got := doRoundTrip(t, "/api/v1/usage/billable-summary", "month=2024-03") + if got != "2024-03" { + t.Fatalf("expected month=2024-03, got month=%s", got) + } +} + +func TestOtherEndpointsUntouched(t *testing.T) { + got := doRoundTrip(t, "/api/v2/usage/hourly_usage", "month=2024-03-01T00%3A00%3A00Z") + if got != "2024-03-01T00:00:00Z" { + t.Fatalf("expected month unchanged, got month=%s", got) + } +} From 2c17f2472e299d9058a2c6b92d5924d2d2494127 Mon Sep 17 00:00:00 2001 From: Scott Holodak Date: Fri, 31 Jul 2026 16:56:28 -0400 Subject: [PATCH 2/2] fix(datadog): wire month-param transport; guard nil NextRecordId (SIGSEGV) Install the MonthParamRoundTripper on the Datadog API client so the billable-summary month parameter reaches the API as YYYY-MM (#85). Also guard the pagination NextRecordId deref: NullableString.IsSet() returns true when the API sends an explicit JSON null at the end of pagination, but Get() then returns nil, so the existing check crashed with SIGSEGV once pricing succeeded. Same fix as the now-stale #70. With both changes the plugin retrieves real costs end-to-end again (verified against a live us5 Datadog org). Fixes #68 --- pkg/plugins/datadog/cmd/main/main.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/plugins/datadog/cmd/main/main.go b/pkg/plugins/datadog/cmd/main/main.go index 8720750..c0ab27e 100644 --- a/pkg/plugins/datadog/cmd/main/main.go +++ b/pkg/plugins/datadog/cmd/main/main.go @@ -233,7 +233,10 @@ func (d *DatadogCostSource) getDDCostsForWindow(window opencost.Window, listPric } } } - if resp.Meta != nil && resp.Meta.Pagination != nil && resp.Meta.Pagination.NextRecordId.IsSet() { + // NextRecordId is a NullableString: IsSet() is true even when the API + // returned an explicit JSON null (end of pagination), in which case + // Get() returns nil. Guard against that to avoid a SIGSEGV (#68). + if resp.Meta != nil && resp.Meta.Pagination != nil && resp.Meta.Pagination.NextRecordId.IsSet() && resp.Meta.Pagination.NextRecordId.Get() != nil { nextPageId = *resp.Meta.Pagination.NextRecordId.Get() } else { nextPageId = "" @@ -468,6 +471,16 @@ func getDatadogClients(config datadogplugin.DatadogConfig) (context.Context, *da ) configuration := datadog.NewConfiguration() + // The generated client serializes the billable-summary `month` parameter + // as a full RFC3339 timestamp, which the Datadog API rejects with + // 400 "month must be in the format YYYY-MM". Rewrite it in transit. + var baseTransport _nethttp.RoundTripper = _nethttp.DefaultTransport + if configuration.HTTPClient != nil && configuration.HTTPClient.Transport != nil { + baseTransport = configuration.HTTPClient.Transport + } + configuration.HTTPClient = &_nethttp.Client{ + Transport: datadogplugin.MonthParamRoundTripper{Next: baseTransport}, + } apiClient := datadog.NewAPIClient(configuration) usageAPI := datadogV2.NewUsageMeteringApi(apiClient) v1UsageAPI := datadogV1.NewUsageMeteringApi(apiClient)