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) 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) + } +}