Skip to content

Commit 696f18f

Browse files
authored
feat(api): add csv support for v3 meter query (#4196)
1 parent 087e9c1 commit 696f18f

8 files changed

Lines changed: 472 additions & 143 deletions

File tree

api/spec/packages/aip/src/meters/operations.tsp

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,73 @@ interface MetersOperations {
9595

9696
/**
9797
* Query a meter for usage.
98+
*
99+
* Set `Accept: application/json` (the default) to get a structured JSON response.
100+
* Set `Accept: text/csv` to download the same data as a CSV file suitable for
101+
* spreadsheets. The CSV columns, in order, are:
102+
*
103+
* `from, to, [subject,] [customer_id, customer_key, customer_name,] <dimensions...>, value`
104+
*
105+
* The `subject` column is emitted only when `subject` is in the query's
106+
* `group_by_dimensions`.
107+
* The three `customer_*` columns are emitted together only when `customer_id`
108+
* is in the query's `group_by_dimensions`.
98109
*/
99110
@extension(Shared.PrivateExtension, true)
100111
@extension(Shared.UnstableExtension, true)
101112
@extension(Shared.InternalExtension, true)
102113
@post
103114
@operationId("query-meter")
104115
@summary("Query meter")
105-
query(
116+
@sharedRoute
117+
query(@path meterId: Shared.ULID, @body request: MeterQueryRequest): {
118+
@header contentType: "application/json";
119+
@body _: MeterQueryResult;
120+
} | Common.NotFound | Common.ErrorResponses;
121+
122+
#suppress "@openmeter/api-spec-aip/operation-summary" "Avoid duplicating the summary in OpenAPI yaml"
123+
@extension(Shared.PrivateExtension, true)
124+
@extension(Shared.UnstableExtension, true)
125+
@extension(Shared.InternalExtension, true)
126+
@opExample(
127+
#{
128+
returnType: #{
129+
contentType: "text/csv",
130+
_: """
131+
from,to,value
132+
2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,12
133+
2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,20
134+
2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,4
135+
""",
136+
},
137+
},
138+
#{ title: "No group-by dimensions" }
139+
)
140+
@opExample(
141+
#{
142+
returnType: #{
143+
contentType: "text/csv",
144+
_: """
145+
from,to,customer_id,customer_key,customer_name,model,type,value
146+
2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,12
147+
2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,20
148+
2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,01G65Z755B3YZ1KRA9W8V7PS2D,globex,Globex Corp,gpt-4-turbo,output,4
149+
""",
150+
},
151+
},
152+
#{ title: "Grouped by customer and dimensions" }
153+
)
154+
@post
155+
@operationId("query-meter")
156+
@sharedRoute
157+
queryCsv(
106158
@path meterId: Shared.ULID,
107-
@body request: MeterQueryRequest,
108-
): MeterQueryResult | Common.NotFound | Common.ErrorResponses;
159+
// Note: to avoid generating anyOf in the OpenAPI yaml, we omit the request body
160+
// @body request: MeterQueryRequest
161+
): {
162+
@header contentType: "text/csv";
163+
164+
@body
165+
_: string;
166+
} | Common.NotFound | Common.ErrorResponses;
109167
}

api/v3/api.gen.go

Lines changed: 134 additions & 125 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/handlers/meters/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Handler interface {
1616
UpdateMeter() UpdateMeterHandler
1717
DeleteMeter() DeleteMeterHandler
1818
QueryMeter() QueryMeterHandler
19+
QueryMeterCSV() QueryMeterCSVHandler
1920
}
2021

2122
type handler struct {
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package meters
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"slices"
8+
"strconv"
9+
"time"
10+
11+
"github.com/samber/lo"
12+
13+
api "github.com/openmeterio/openmeter/api/v3"
14+
"github.com/openmeterio/openmeter/api/v3/apierrors"
15+
"github.com/openmeterio/openmeter/api/v3/handlers/meters/query"
16+
"github.com/openmeterio/openmeter/api/v3/request"
17+
"github.com/openmeterio/openmeter/openmeter/customer"
18+
"github.com/openmeterio/openmeter/openmeter/meter"
19+
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
20+
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
21+
"github.com/openmeterio/openmeter/pkg/models"
22+
)
23+
24+
type (
25+
QueryMeterCSVRequest = QueryMeterRequest
26+
QueryMeterCSVResponse = commonhttp.CSVResponse
27+
QueryMeterCSVParams = QueryMeterParams
28+
QueryMeterCSVHandler httptransport.HandlerWithArgs[QueryMeterCSVRequest, QueryMeterCSVResponse, QueryMeterCSVParams]
29+
)
30+
31+
const (
32+
csvColumnFrom = "from"
33+
csvColumnTo = "to"
34+
csvColumnValue = "value"
35+
csvColumnCustomerID = "customer_id"
36+
csvColumnCustomerKey = "customer_key"
37+
csvColumnCustomerName = "customer_name"
38+
)
39+
40+
func (h *handler) QueryMeterCSV() QueryMeterCSVHandler {
41+
return httptransport.NewHandlerWithArgs(
42+
func(ctx context.Context, r *http.Request, meterID QueryMeterCSVParams) (QueryMeterCSVRequest, error) {
43+
ns, err := h.resolveNamespace(ctx)
44+
if err != nil {
45+
return QueryMeterCSVRequest{}, err
46+
}
47+
48+
var body api.MeterQueryRequest
49+
if err := request.ParseBody(r, &body); err != nil {
50+
return QueryMeterCSVRequest{}, err
51+
}
52+
53+
return QueryMeterCSVRequest{
54+
NamespacedID: models.NamespacedID{
55+
Namespace: ns,
56+
ID: meterID,
57+
},
58+
Body: body,
59+
}, nil
60+
},
61+
func(ctx context.Context, req QueryMeterCSVRequest) (QueryMeterCSVResponse, error) {
62+
m, err := h.service.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{
63+
Namespace: req.Namespace,
64+
IDOrSlug: req.ID,
65+
})
66+
if err != nil {
67+
return nil, err
68+
}
69+
70+
params, err := query.BuildQueryParams(ctx, m, req.Body, query.NewCustomerResolver(h.customerService))
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
rows, err := h.streaming.QueryMeter(ctx, req.Namespace, m, params)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
// Enrich with customer metadata (key, name) when any row has a CustomerID.
81+
customerIDs := collectCustomerIDs(rows)
82+
83+
var customersByID map[string]customer.Customer
84+
if len(customerIDs) > 0 {
85+
result, err := h.customerService.ListCustomers(ctx, customer.ListCustomersInput{
86+
Namespace: req.Namespace,
87+
CustomerIDs: customerIDs,
88+
})
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to list customers for csv enrichment: %w", err)
91+
}
92+
93+
customersByID = lo.KeyBy(result.Items, func(c customer.Customer) string {
94+
return c.ID
95+
})
96+
}
97+
98+
return newQueryMeterCSVResult(m.Key, params.GroupBy, rows, customersByID), nil
99+
},
100+
commonhttp.CSVResponseEncoder[QueryMeterCSVResponse],
101+
httptransport.AppendOptions(
102+
h.options,
103+
httptransport.WithOperationName("query-meter-csv"),
104+
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
105+
)...,
106+
)
107+
}
108+
109+
// collectCustomerIDs returns the distinct non-nil customer IDs referenced by the rows.
110+
func collectCustomerIDs(rows []meter.MeterQueryRow) []string {
111+
ids := make([]string, 0)
112+
for _, row := range rows {
113+
if row.CustomerID == nil {
114+
continue
115+
}
116+
ids = append(ids, *row.CustomerID)
117+
}
118+
return lo.Uniq(ids)
119+
}
120+
121+
// Column order:
122+
//
123+
// from, to,
124+
// [subject,]
125+
// [customer_id, customer_key, customer_name,]
126+
// <other dimensions...>,
127+
// value
128+
type queryMeterCSVResult struct {
129+
meterSlug string
130+
groupBy []string
131+
rows []meter.MeterQueryRow
132+
customersByID map[string]customer.Customer
133+
}
134+
135+
var _ commonhttp.CSVResponse = &queryMeterCSVResult{}
136+
137+
func newQueryMeterCSVResult(
138+
meterSlug string,
139+
groupBy []string,
140+
rows []meter.MeterQueryRow,
141+
customersByID map[string]customer.Customer,
142+
) *queryMeterCSVResult {
143+
return &queryMeterCSVResult{
144+
meterSlug: meterSlug,
145+
groupBy: groupBy,
146+
rows: rows,
147+
customersByID: customersByID,
148+
}
149+
}
150+
151+
func (r *queryMeterCSVResult) FileName() string {
152+
return r.meterSlug
153+
}
154+
155+
func (r *queryMeterCSVResult) Records() [][]string {
156+
hasSubjectColumn := slices.Contains(r.groupBy, query.DimensionSubject)
157+
hasCustomerColumns := slices.Contains(r.groupBy, query.DimensionCustomerID)
158+
159+
otherDimensions := make([]string, 0, len(r.groupBy))
160+
for _, k := range r.groupBy {
161+
switch k {
162+
case query.DimensionSubject, query.DimensionCustomerID:
163+
// Handled as reserved columns.
164+
default:
165+
otherDimensions = append(otherDimensions, k)
166+
}
167+
}
168+
169+
headers := []string{csvColumnFrom, csvColumnTo}
170+
if hasSubjectColumn {
171+
headers = append(headers, query.DimensionSubject)
172+
}
173+
if hasCustomerColumns {
174+
headers = append(headers, csvColumnCustomerID, csvColumnCustomerKey, csvColumnCustomerName)
175+
}
176+
headers = append(headers, otherDimensions...)
177+
headers = append(headers, csvColumnValue)
178+
179+
records := make([][]string, 0, len(r.rows)+1)
180+
records = append(records, headers)
181+
182+
for _, row := range r.rows {
183+
record := make([]string, 0, len(headers))
184+
record = append(record,
185+
row.WindowStart.Format(time.RFC3339),
186+
row.WindowEnd.Format(time.RFC3339),
187+
)
188+
189+
if hasSubjectColumn {
190+
record = append(record, lo.FromPtrOr(row.Subject, ""))
191+
}
192+
193+
if hasCustomerColumns {
194+
var id, key, name string
195+
if row.CustomerID != nil {
196+
id = *row.CustomerID
197+
if c, ok := r.customersByID[id]; ok {
198+
key = lo.FromPtrOr(c.Key, "")
199+
name = c.Name
200+
}
201+
}
202+
record = append(record,
203+
id,
204+
key,
205+
name,
206+
)
207+
}
208+
209+
for _, k := range otherDimensions {
210+
var v string
211+
if ptr, ok := row.GroupBy[k]; ok && ptr != nil {
212+
v = *ptr
213+
}
214+
record = append(record, v)
215+
}
216+
217+
record = append(record, strconv.FormatFloat(row.Value, 'f', -1, 64))
218+
records = append(records, record)
219+
}
220+
221+
return records
222+
}

api/v3/openapi.yaml

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,21 +1685,51 @@ paths:
16851685
- Meters
16861686
post:
16871687
operationId: query-meter
1688-
summary: Query meter
1689-
description: Query a meter for usage.
16901688
parameters:
16911689
- name: meterId
16921690
in: path
16931691
required: true
16941692
schema:
16951693
$ref: '#/components/schemas/ULID'
1694+
description: |-
1695+
Query a meter for usage.
1696+
1697+
Set `Accept: application/json` (the default) to get a structured JSON response.
1698+
Set `Accept: text/csv` to download the same data as a CSV file suitable for
1699+
spreadsheets. The CSV columns, in order, are:
1700+
1701+
`from, to, [subject,] [customer_id, customer_key, customer_name,] <dimensions...>, value`
1702+
1703+
The `subject` column is emitted only when `subject` is in the query's
1704+
`group_by_dimensions`.
1705+
The three `customer_*` columns are emitted together only when `customer_id`
1706+
is in the query's `group_by_dimensions`.
1707+
summary: Query meter
16961708
responses:
16971709
'200':
16981710
description: The request has succeeded.
16991711
content:
17001712
application/json:
17011713
schema:
17021714
$ref: '#/components/schemas/MeterQueryResult'
1715+
text/csv:
1716+
schema:
1717+
type: string
1718+
examples:
1719+
Grouped by customer and dimensions:
1720+
summary: Grouped by customer and dimensions
1721+
value: |-
1722+
from,to,customer_id,customer_key,customer_name,model,type,value
1723+
2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,12
1724+
2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,20
1725+
2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,01G65Z755B3YZ1KRA9W8V7PS2D,globex,Globex Corp,gpt-4-turbo,output,4
1726+
No group-by dimensions:
1727+
summary: No group-by dimensions
1728+
value: |-
1729+
from,to,value
1730+
2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,12
1731+
2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,20
1732+
2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,4
17031733
'400':
17041734
$ref: '#/components/responses/BadRequest'
17051735
'401':
@@ -1708,6 +1738,9 @@ paths:
17081738
$ref: '#/components/responses/Forbidden'
17091739
'404':
17101740
$ref: '#/components/responses/NotFound'
1741+
x-internal: true
1742+
x-unstable: true
1743+
x-private: true
17111744
tags:
17121745
- Meters
17131746
requestBody:
@@ -1716,9 +1749,6 @@ paths:
17161749
application/json:
17171750
schema:
17181751
$ref: '#/components/schemas/MeterQueryRequest'
1719-
x-internal: true
1720-
x-unstable: true
1721-
x-private: true
17221752
/openmeter/plans:
17231753
get:
17241754
operationId: list-plans

0 commit comments

Comments
 (0)