diff --git a/coderd/usage/usagetypes/events.go b/coderd/usage/usagetypes/events.go index a8558fc49090e..ef5ac79d455fa 100644 --- a/coderd/usage/usagetypes/events.go +++ b/coderd/usage/usagetypes/events.go @@ -13,6 +13,7 @@ package usagetypes import ( "bytes" "encoding/json" + "fmt" "strings" "golang.org/x/xerrors" @@ -22,6 +23,10 @@ import ( // type `usage_event_type`. type UsageEventType string +// All event types. +// +// When adding a new event type, ensure you add it to the Valid method and the +// ParseEventWithType function. const ( UsageEventTypeDCManagedAgentsV1 UsageEventType = "dc_managed_agents_v1" ) @@ -43,38 +48,56 @@ func (e UsageEventType) IsHeartbeat() bool { return e.Valid() && strings.HasPrefix(string(e), "hb_") } -// ParseEvent parses the raw event data into the specified Go type. It fails if -// there is any unknown fields or extra data after the event. The returned event -// is validated. -func ParseEvent[T Event](data json.RawMessage) (T, error) { +// ParseEvent parses the raw event data into the provided event. It fails if +// there is any unknown fields or extra data at the end of the JSON. The +// returned event is validated. +func ParseEvent(data json.RawMessage, out Event) error { dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() - var event T - err := dec.Decode(&event) + err := dec.Decode(out) if err != nil { - return event, xerrors.Errorf("unmarshal %T event: %w", event, err) + return xerrors.Errorf("unmarshal %T event: %w", out, err) } if dec.More() { - return event, xerrors.Errorf("extra data after %T event", event) + return xerrors.Errorf("extra data after %T event", out) } - err = event.Valid() + err = out.Valid() if err != nil { - return event, xerrors.Errorf("invalid %T event: %w", event, err) + return xerrors.Errorf("invalid %T event: %w", out, err) } - return event, nil + return nil +} + +// UnknownEventTypeError is returned by ParseEventWithType when an unknown event +// type is encountered. +type UnknownEventTypeError struct { + EventType string +} + +var _ error = UnknownEventTypeError{} + +// Error implements error. +func (e UnknownEventTypeError) Error() string { + return fmt.Sprintf("unknown usage event type: %q", e.EventType) } // ParseEventWithType parses the raw event data into the specified Go type. It // fails if there is any unknown fields or extra data after the event. The // returned event is validated. +// +// If the event type is unknown, UnknownEventTypeError is returned. func ParseEventWithType(eventType UsageEventType, data json.RawMessage) (Event, error) { switch eventType { case UsageEventTypeDCManagedAgentsV1: - return ParseEvent[DCManagedAgentsV1](data) + var event DCManagedAgentsV1 + if err := ParseEvent(data, &event); err != nil { + return nil, err + } + return event, nil default: - return nil, xerrors.Errorf("unknown event type: %s", eventType) + return nil, UnknownEventTypeError{EventType: string(eventType)} } } diff --git a/coderd/usage/usagetypes/events_test.go b/coderd/usage/usagetypes/events_test.go index 1e09aa07851c3..a04e5d4df025b 100644 --- a/coderd/usage/usagetypes/events_test.go +++ b/coderd/usage/usagetypes/events_test.go @@ -13,29 +13,34 @@ func TestParseEvent(t *testing.T) { t.Run("ExtraFields", func(t *testing.T) { t.Parallel() - _, err := usagetypes.ParseEvent[usagetypes.DCManagedAgentsV1]([]byte(`{"count": 1, "extra": "field"}`)) - require.ErrorContains(t, err, "unmarshal usagetypes.DCManagedAgentsV1 event") + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1, "extra": "field"}`), &event) + require.ErrorContains(t, err, "unmarshal *usagetypes.DCManagedAgentsV1 event") }) t.Run("ExtraData", func(t *testing.T) { t.Parallel() - _, err := usagetypes.ParseEvent[usagetypes.DCManagedAgentsV1]([]byte(`{"count": 1}{"count": 2}`)) - require.ErrorContains(t, err, "extra data after usagetypes.DCManagedAgentsV1 event") + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1}{"count": 2}`), &event) + require.ErrorContains(t, err, "extra data after *usagetypes.DCManagedAgentsV1 event") }) t.Run("DCManagedAgentsV1", func(t *testing.T) { t.Parallel() - event, err := usagetypes.ParseEvent[usagetypes.DCManagedAgentsV1]([]byte(`{"count": 1}`)) + var event usagetypes.DCManagedAgentsV1 + err := usagetypes.ParseEvent([]byte(`{"count": 1}`), &event) require.NoError(t, err) require.Equal(t, usagetypes.DCManagedAgentsV1{Count: 1}, event) require.Equal(t, map[string]any{"count": uint64(1)}, event.Fields()) - _, err = usagetypes.ParseEvent[usagetypes.DCManagedAgentsV1]([]byte(`{"count": "invalid"}`)) - require.ErrorContains(t, err, "unmarshal usagetypes.DCManagedAgentsV1 event") + event = usagetypes.DCManagedAgentsV1{} + err = usagetypes.ParseEvent([]byte(`{"count": "invalid"}`), &event) + require.ErrorContains(t, err, "unmarshal *usagetypes.DCManagedAgentsV1 event") - _, err = usagetypes.ParseEvent[usagetypes.DCManagedAgentsV1]([]byte(`{}`)) - require.ErrorContains(t, err, "invalid usagetypes.DCManagedAgentsV1 event: count must be greater than 0") + event = usagetypes.DCManagedAgentsV1{} + err = usagetypes.ParseEvent([]byte(`{}`), &event) + require.ErrorContains(t, err, "invalid *usagetypes.DCManagedAgentsV1 event: count must be greater than 0") }) } @@ -45,7 +50,9 @@ func TestParseEventWithType(t *testing.T) { t.Run("UnknownEvent", func(t *testing.T) { t.Parallel() _, err := usagetypes.ParseEventWithType(usagetypes.UsageEventType("fake"), []byte(`{}`)) - require.ErrorContains(t, err, "unknown event type: fake") + var unknownEventTypeError usagetypes.UnknownEventTypeError + require.ErrorAs(t, err, &unknownEventTypeError) + require.Equal(t, "fake", unknownEventTypeError.EventType) }) t.Run("DCManagedAgentsV1", func(t *testing.T) { diff --git a/enterprise/coderd/usage/publisher.go b/enterprise/coderd/usage/publisher.go index 5c205ecd8c3b8..daeec7eb11b27 100644 --- a/enterprise/coderd/usage/publisher.go +++ b/enterprise/coderd/usage/publisher.go @@ -14,6 +14,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -396,6 +397,7 @@ func (p *tallymanPublisher) sendPublishRequest(ctx context.Context, deploymentID if err != nil { return usagetypes.TallymanV1IngestResponse{}, err } + r.Header.Set("User-Agent", "coderd/"+buildinfo.Version()) r.Header.Set(usagetypes.TallymanCoderLicenseKeyHeader, licenseJwt) r.Header.Set(usagetypes.TallymanCoderDeploymentIDHeader, deploymentID.String())