Skip to content

Commit 7ee1daa

Browse files
committed
feat(tallymansdk): add tallyman SDK with events and dashboards support
1 parent 6f86f67 commit 7ee1daa

File tree

4 files changed

+223
-44
lines changed

4 files changed

+223
-44
lines changed

enterprise/coderd/usage/publisher.go

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,30 @@
11
package usage
22

33
import (
4-
"bytes"
54
"context"
65
"crypto/ed25519"
7-
"encoding/json"
86
"fmt"
97
"io"
108
"net/http"
9+
"net/url"
1110
"time"
1211

1312
"github.com/google/uuid"
1413
"golang.org/x/xerrors"
1514

1615
"cdr.dev/slog"
17-
"github.com/coder/coder/v2/buildinfo"
1816
"github.com/coder/coder/v2/coderd/database"
1917
"github.com/coder/coder/v2/coderd/database/dbauthz"
2018
"github.com/coder/coder/v2/coderd/database/dbtime"
2119
"github.com/coder/coder/v2/coderd/pproflabel"
2220
"github.com/coder/coder/v2/coderd/usage/usagetypes"
2321
"github.com/coder/coder/v2/cryptorand"
2422
"github.com/coder/coder/v2/enterprise/coderd/license"
23+
"github.com/coder/coder/v2/enterprise/coderd/usage/tallymansdk"
2524
"github.com/coder/quartz"
2625
)
2726

2827
const (
29-
tallymanURL = "https://tallyman-prod.coder.com"
30-
tallymanIngestURLV1 = tallymanURL + "/api/v1/events/ingest"
31-
3228
tallymanPublishInitialMinimumDelay = 5 * time.Minute
3329
// Chosen to be a prime number and not a multiple of 5 like many other
3430
// recurring tasks.
@@ -56,7 +52,7 @@ type tallymanPublisher struct {
5652
done chan struct{}
5753

5854
// Configured with options:
59-
ingestURL string
55+
baseURL *url.URL
6056
httpClient *http.Client
6157
clock quartz.Clock
6258
initialDelay time.Duration
@@ -70,6 +66,7 @@ func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Stor
7066
ctx, cancel := context.WithCancel(ctx)
7167
ctx = dbauthz.AsUsagePublisher(ctx) //nolint:gocritic // we intentionally want to be able to process usage events
7268

69+
baseURL, _ := url.Parse(tallymansdk.DefaultURL)
7370
publisher := &tallymanPublisher{
7471
ctx: ctx,
7572
ctxCancel: cancel,
@@ -78,7 +75,7 @@ func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Stor
7875
licenseKeys: keys,
7976
done: make(chan struct{}),
8077

81-
ingestURL: tallymanIngestURLV1,
78+
baseURL: baseURL,
8279
httpClient: http.DefaultClient,
8380
clock: quartz.NewReal(),
8481
}
@@ -108,10 +105,18 @@ func PublisherWithClock(clock quartz.Clock) TallymanPublisherOption {
108105
}
109106

110107
// PublisherWithIngestURL sets the ingest URL to use for publishing usage
111-
// events.
108+
// events. The base URL is extracted from the ingest URL.
112109
func PublisherWithIngestURL(ingestURL string) TallymanPublisherOption {
113110
return func(p *tallymanPublisher) {
114-
p.ingestURL = ingestURL
111+
parsed, err := url.Parse(ingestURL)
112+
if err != nil {
113+
// This shouldn't happen in practice, but if it does, keep the default.
114+
return
115+
}
116+
p.baseURL = &url.URL{
117+
Scheme: parsed.Scheme,
118+
Host: parsed.Host,
119+
}
115120
}
116121
}
117122

@@ -388,41 +393,16 @@ func (p *tallymanPublisher) getBestLicenseJWT(ctx context.Context) (string, erro
388393
}
389394

390395
func (p *tallymanPublisher) sendPublishRequest(ctx context.Context, deploymentID uuid.UUID, licenseJwt string, req usagetypes.TallymanV1IngestRequest) (usagetypes.TallymanV1IngestResponse, error) {
391-
body, err := json.Marshal(req)
392-
if err != nil {
393-
return usagetypes.TallymanV1IngestResponse{}, err
394-
}
395-
396-
r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.ingestURL, bytes.NewReader(body))
397-
if err != nil {
398-
return usagetypes.TallymanV1IngestResponse{}, err
399-
}
400-
r.Header.Set("User-Agent", "coderd/"+buildinfo.Version())
401-
r.Header.Set(usagetypes.TallymanCoderLicenseKeyHeader, licenseJwt)
402-
r.Header.Set(usagetypes.TallymanCoderDeploymentIDHeader, deploymentID.String())
403-
404-
resp, err := p.httpClient.Do(r)
405-
if err != nil {
406-
return usagetypes.TallymanV1IngestResponse{}, err
407-
}
408-
defer resp.Body.Close()
409-
410-
if resp.StatusCode != http.StatusOK {
411-
var errBody usagetypes.TallymanV1Response
412-
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
413-
errBody = usagetypes.TallymanV1Response{
414-
Message: fmt.Sprintf("could not decode error response body: %v", err),
415-
}
416-
}
417-
return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("unexpected status code %v, error: %s", resp.StatusCode, errBody.Message)
418-
}
419-
420-
var respBody usagetypes.TallymanV1IngestResponse
421-
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
422-
return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("decode response body: %w", err)
423-
}
396+
// Create a new SDK client for this request.
397+
// We create it per-request since the license key may change.
398+
sdkClient := tallymansdk.New(
399+
p.baseURL,
400+
licenseJwt,
401+
deploymentID,
402+
tallymansdk.WithHTTPClient(p.httpClient),
403+
)
424404

425-
return respBody, nil
405+
return sdkClient.PublishUsageEvents(ctx, req)
426406
}
427407

428408
// Close implements Publisher.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package tallymansdk
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
11+
"github.com/google/uuid"
12+
"golang.org/x/xerrors"
13+
14+
"github.com/coder/coder/v2/buildinfo"
15+
"github.com/coder/coder/v2/coderd/usage/usagetypes"
16+
)
17+
18+
const (
19+
// DefaultURL is the default URL for the Tallyman API.
20+
DefaultURL = "https://tallyman-prod.coder.com"
21+
)
22+
23+
// Client is a client for the Tallyman API.
24+
type Client struct {
25+
// URL is the base URL for the Tallyman API.
26+
URL *url.URL
27+
// HTTPClient is the HTTP client to use for requests.
28+
HTTPClient *http.Client
29+
// licenseKey is the Coder license key for authentication.
30+
licenseKey string
31+
// deploymentID is the deployment ID for authentication.
32+
deploymentID uuid.UUID
33+
}
34+
35+
// ClientOption is a functional option for configuring the Client.
36+
type ClientOption func(*Client)
37+
38+
// New creates a new Tallyman API client.
39+
func New(baseURL *url.URL, licenseKey string, deploymentID uuid.UUID, opts ...ClientOption) *Client {
40+
if baseURL == nil {
41+
baseURL, _ = url.Parse(DefaultURL)
42+
}
43+
44+
c := &Client{
45+
URL: baseURL,
46+
HTTPClient: http.DefaultClient,
47+
licenseKey: licenseKey,
48+
deploymentID: deploymentID,
49+
}
50+
51+
for _, opt := range opts {
52+
opt(c)
53+
}
54+
55+
return c
56+
}
57+
58+
// WithHTTPClient sets the HTTP client to use for requests.
59+
func WithHTTPClient(httpClient *http.Client) ClientOption {
60+
return func(c *Client) {
61+
if httpClient != nil {
62+
c.HTTPClient = httpClient
63+
}
64+
}
65+
}
66+
67+
// Request makes an HTTP request to the Tallyman API.
68+
// It sets the authentication headers and User-Agent automatically.
69+
func (c *Client) Request(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
70+
var bodyReader io.Reader
71+
if body != nil {
72+
bodyBytes, err := json.Marshal(body)
73+
if err != nil {
74+
return nil, xerrors.Errorf("marshal request body: %w", err)
75+
}
76+
bodyReader = bytes.NewReader(bodyBytes)
77+
}
78+
79+
// Build the full URL
80+
endpoint, err := url.Parse(path)
81+
if err != nil {
82+
return nil, xerrors.Errorf("parse path %q: %w", path, err)
83+
}
84+
fullURL := c.URL.ResolveReference(endpoint)
85+
86+
req, err := http.NewRequestWithContext(ctx, method, fullURL.String(), bodyReader)
87+
if err != nil {
88+
return nil, xerrors.Errorf("create request: %w", err)
89+
}
90+
91+
// Set headers
92+
req.Header.Set("Content-Type", "application/json")
93+
req.Header.Set("User-Agent", "coderd/"+buildinfo.Version())
94+
req.Header.Set(usagetypes.TallymanCoderLicenseKeyHeader, c.licenseKey)
95+
req.Header.Set(usagetypes.TallymanCoderDeploymentIDHeader, c.deploymentID.String())
96+
97+
resp, err := c.HTTPClient.Do(req)
98+
if err != nil {
99+
return nil, xerrors.Errorf("do request: %w", err)
100+
}
101+
102+
return resp, nil
103+
}
104+
105+
// readErrorResponse parses a Tallyman error response from an HTTP response body.
106+
func readErrorResponse(resp *http.Response) error {
107+
var errBody usagetypes.TallymanV1Response
108+
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
109+
errBody = usagetypes.TallymanV1Response{
110+
Message: "could not decode error response body",
111+
}
112+
}
113+
return xerrors.Errorf("unexpected status code %v, error: %s", resp.StatusCode, errBody.Message)
114+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package tallymansdk
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
8+
"golang.org/x/xerrors"
9+
)
10+
11+
// DashboardType represents the type of dashboard to embed.
12+
type DashboardType string
13+
14+
const (
15+
// DashboardTypeUsage is the usage dashboard type.
16+
DashboardTypeUsage DashboardType = "usage"
17+
)
18+
19+
// DashboardColorOverride represents a color override for a dashboard.
20+
type DashboardColorOverride struct {
21+
Name string `json:"name"`
22+
Value string `json:"value"`
23+
}
24+
25+
// RetrieveEmbeddableDashboardRequest is a request to get an embed URL for a dashboard.
26+
type RetrieveEmbeddableDashboardRequest struct {
27+
Dashboard DashboardType `json:"dashboard"`
28+
ColorOverrides []DashboardColorOverride `json:"color_overrides,omitempty"`
29+
}
30+
31+
// RetrieveEmbeddableDashboardResponse is a response containing a dashboard embed URL.
32+
type RetrieveEmbeddableDashboardResponse struct {
33+
DashboardURL string `json:"dashboard_url"`
34+
}
35+
36+
// RetrieveEmbeddableDashboard retrieves an embed URL for a dashboard from the Tallyman API.
37+
func (c *Client) RetrieveEmbeddableDashboard(ctx context.Context, req RetrieveEmbeddableDashboardRequest) (RetrieveEmbeddableDashboardResponse, error) {
38+
resp, err := c.Request(ctx, http.MethodPost, "/api/v1/dashboards/embed", req)
39+
if err != nil {
40+
return RetrieveEmbeddableDashboardResponse{}, err
41+
}
42+
defer resp.Body.Close()
43+
44+
if resp.StatusCode != http.StatusOK {
45+
return RetrieveEmbeddableDashboardResponse{}, readErrorResponse(resp)
46+
}
47+
48+
var respBody RetrieveEmbeddableDashboardResponse
49+
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
50+
return RetrieveEmbeddableDashboardResponse{}, xerrors.Errorf("decode response body: %w", err)
51+
}
52+
53+
return respBody, nil
54+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package tallymansdk
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/coder/v2/coderd/usage/usagetypes"
11+
)
12+
13+
// PublishUsageEvents publishes usage events to the Tallyman API.
14+
func (c *Client) PublishUsageEvents(ctx context.Context, req usagetypes.TallymanV1IngestRequest) (usagetypes.TallymanV1IngestResponse, error) {
15+
resp, err := c.Request(ctx, http.MethodPost, "/api/v1/events/ingest", req)
16+
if err != nil {
17+
return usagetypes.TallymanV1IngestResponse{}, err
18+
}
19+
defer resp.Body.Close()
20+
21+
if resp.StatusCode != http.StatusOK {
22+
return usagetypes.TallymanV1IngestResponse{}, readErrorResponse(resp)
23+
}
24+
25+
var respBody usagetypes.TallymanV1IngestResponse
26+
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
27+
return usagetypes.TallymanV1IngestResponse{}, xerrors.Errorf("decode response body: %w", err)
28+
}
29+
30+
return respBody, nil
31+
}

0 commit comments

Comments
 (0)