Є задачка на моніторинг костів на OpenAI – бачити скільки за добу витрачено кожним проектом, і слати алерти в Slack, коли витрати завеликі.
Потикав кілька готових експортерів для OpenAI, але не побачив там метрик саме по костам, тому просто напишемо свій.
Писати будемо на Golang, ідея дуже проста – з OpenAI API отримуємо дані, генеруємо метрику, відправляємо її до VictoriaMetrics.
На Go останній раз писав у 2019 році, і то один раз, тому заодно будемо згадувати що і як працює, і місцями дивитись деталі реалізації різних бібліотек.
Поїхали.
Зміст
OpenAI API
Документація по OpenAI API – Costs та повертаєме значення – Costs object.
Для доступу до Costs потрібен окремий ключ – робимо на platform.openai.com в Admin keys:
Для отримання Costs треба задавати параметр start_time в Unix форматі – створюємо змінну:
$ TODAY=$(date -d "$(date +%Y-%m-%d) 00:00:00" +%s) $ echo $TODAY 1762898400
І перевіряємо доступ з curl:
$ curl -s -H "Content-Type: application/json" -H "Authorization: Bearer $OPENAI_ADMIN_KEY" "https://api.openai.com/v1/organization/costs?start_time=$TODAY"
{
"object": "page",
"has_more": false,
"next_page": null,
"data": [
{
"object": "bucket",
"start_time": 1762819200,
"end_time": 1762905600,
"results": [
{
"object": "organization.costs.result",
"amount": {
"value": 5.65750295,
"currency": "usd"
...
ОК, доступ працює – поїхали до Golang.
Golang gore REPL
Для швидкого тестування функцій можна встановити gore:
$ go install github.com/x-motemen/gore/cmd/gore@latest
Запускаємо (не забуваємо про $GOPATH/bin в $PATH) і перевіряємо отримання поточної дати і часу:
$ gore gore version 0.6.1 :help for help gore> :import time gore> time.Now() %!t(int64=1763025027)2025-11-13 11:10:27 Local
Ну або просто користуватись Go Playground.
Створення Golang API client
Спочатку напишемо клієнт, який буде звертатись до API та виводити отриманий результат на консоль, а потім допишемо генерацію метрик.
Що нам треба для запитів:
- мати URL
- мати час
- отриманий результат поки просто виводимо на консоль
Створюємо каталог проекту, виконуємо ініціалізацію:
$ mkdir ~/Work/atlas-monitoring/exporters/openai-exporter $ go mod init openai-exporter
Для API-клієнту можемо використати стандартну бібліотеку net/http, або більш спеціалізовані типу resty або sling.
Вирішив спробувати resty, бо і цікаво, і код виглядає приємніше, і зручно передавати параметри.
Документація по resty – тут>>> та тут>>>.
Вже є версія 3, але вона ще в beta, тому беремо другу.
Спробуємо з resty спочатку в gore:
gore> :import "github.com/go-resty/resty/v2"
gore> client := resty.New()
...
gore> resp, err := client.R().Get("https://httpbin.org/get")
...
gore> fmt.Println(resp, err)
...
313
nil
Для виконання API-запитів спочатку викликом методу New() створюємо об’єкт type Client struct, а далі з методом R() (request) робимо виклики.
Документація по New() тут>>>, її код тут>>>.
Не дуже зручно, що типи в Golang не описують пов’язаних методів – але їх можна побачити в go doc:
$ go doc github.com/go-resty/resty/v2.Client | grep "New()\|R()" func New() *Client func (c *Client) R() *Request
В розділі Usage є такий приклад:
...
resp, err := client.R().
EnableTrace().
Get("https://httpbin.org/get")
...
Де resty використовує method chaining, коли методи якогось типу повертають той самий тип.
Як це виглядає:
- з функцією
resty.New()ми створюємо клієнт –New()повертає*Client structз його пов’язаними методами - для
Client structє метод R(), який повертає*Request struct - для структури
Requestмаємо методEnableTrace()який теж повертаєRequest - і для того ж
Requestмаємо методGet(), який теж повертаєRequestплюсerror
І це дозволяє нам будувати ланцюжки запитів – Client => R() => Request => EnableTrace() => Request => Get().
Окей, давайте до коду.
Створення resty клієнта
Пишемо main.go:
package main
import (
"fmt"
"github.com/go-resty/resty/v2"
)
// set global const as ay be used in other packages
const (
baseURL = "https://api.openai.com/v1"
costsPath = "/organization/costs"
)
func main() {
client := resty.New()
// build 'https://api.openai.com/v1/organization/costs'
response, err := client.R().Get(baseURL + costsPath)
if err != nil {
panic(err)
}
fmt.Println(response)
}
Запускаємо:
$ go run main.go
{
"error": {
"message": "You didn't provide an API key. You need to provide your API key in an Authorization header using Bearer auth (i.e. Authorization: Bearer YOUR_KEY). You can obtain an API key from https://platform.openai.com/account/api-keys."
...
Супер, працює.
Тепер додамо отримання API ключа зі змінної.
Використовуємо os:
...
import (
"fmt"
"os"
...
func main() {
client := resty.New()
apiKey = os.Getenv("OPENAI_ADMIN_KEY")
...
Далі нам треба додати auth header до нашого запиту – використовуємо метод func (*Client) SetAuthToken, який просто додає значення до поля Token в об’єкті Client.
Ще є окремий метод func (r *Request) SetAuthToken, який задає токен на конкретні реквести, а не на весь клієнт, але в нашому випадку робимо простіше, через загальний Client.
Робимо method chaining із прикладу вище – для Client викликаємо SetAuthToken(), який задає токен, наступним викликаємо R() для створення request, і наступним викликаємо Get(), в який передаємо URL:
...
apiKey := os.Getenv("OPENAI_ADMIN_KEY")
// build 'https://api.openai.com/v1/organization/costs'
response, err := client.SetAuthToken(apiKey).R().Get(baseURL + costsPath)
...
Перевіряємо:
$ go run main.go
{
"error": {
"message": "Missing query parameter 'start_time'",
...
ОК – аутентифікацію ми пройшли, тепер треба додати параметри.
Тут у нас є цілих чотири варіанти:
- func (r *Request) SetQueryParam(param, value string) *Request: задає один параметр key=value, можна використати, якщо буде 1-2 параметри всього
- func (r *Request) SetQueryParams(params map[string]string) *Request: аналогічно, але приймає
mapзі списком параметрів - func (r *Request) SetQueryParamsFromValues(params url.Values) *Request: якщо використовується бібліотека
http, то можна передати параметри через типurl.Values - func (r *Request) SetQueryString(query string) *Request: передає готовий список параметрів одним одною строкою, наприклад –
SetQueryString("a=1&b=2")
Зараз нам треба тільки start_time, але потім будемо додавати ще, тому можна їх відразу записати в map, який потім передамо до SetQueryParams().
Для start_time нам треба передати час – робимо з time.Now(), і передавати дату до OpenAI API нам треба в Unix форматі, тому використовуємо функцію Unix().
Перевіряємо як воно буде виглядати:
gore> :import time gore> timeNow := time.Now().Unix() 1762956432
Додаємо в код створення змінної timeNow з часом, створення setQueryParams map of strings зі списком параметрів теж в strings, і додаємо виклик SetQueryParams() до client:
...
timeNow := time.Now().Unix()
setQueryParams := map[string]string{
"start_time": timeNow,
}
// build 'https://api.openai.com/v1/organization/costs'
response, err := client.SetAuthToken(apiKey).
R().SetQueryParams(setQueryParams).
Get(baseURL + costsPath)
...
Але якщо викликати цей код зараз, то буде помилка, бо timeNow := time.Now().Unix() повертається в int64:
gore> fmt.Printf("%t", timeNow)
%!t(int64=1762957173)21
nil
А в setQueryParams() нам треба передати string, бо SetQueryParams() приймає map зі string:
func (r *Request) SetQueryParams(params map[string]string) *Request
Тому конвертуємо нашу змінну timeNow в string зі strconv.FormatInt():
gore> :import strconv
gore> s := strconv.FormatInt(timeNow, 10)
gore> fmt.Printf("%t", s)
%!t(string=1763371451)22
І можемо зробити нашу змінну timeNow так:
... timeNow := strconv.FormatInt(time.Now().Unix(), 10) ...
Запускаємо, перевіряємо:
$ go run main.go
{
"object": "page",
"has_more": false,
"next_page": null,
"data": [
{
"object": "bucket",
"start_time": 1762905600,
"end_time": 1762992000,
"results": [
{
"object": "organization.costs.result",
"amount": {
"value": 6.442440250000003,
"currency": "usd"
},
"line_item": null,
"project_id": null,
"organization_id": "org-ORG"
...
Чудово, маємо потрібні дані.
Тепер треба додати ще один параметр – group_by=project_id:
...
setQueryParams := map[string]string{
"start_time": timeNow,
"group_by": "project_id",
}
...
І тепер в результатах маємо дані по кожному project_id:
$ go run main.go
{
"object": "page",
"has_more": false,
"next_page": null,
"data": [
{
"object": "bucket",
"start_time": 1762905600,
"end_time": 1762992000,
"results": [
{
"object": "organization.costs.result",
"amount": {
"value": 1.76643575,
"currency": "usd"
},
"line_item": null,
"project_id": "proj_1",
"organization_id": "org-ORG"
},
{
"object": "organization.costs.result",
"amount": {
"value": 0.47790999999999995,
"currency": "usd"
},
"line_item": null,
"project_id": "proj_2",
"organization_id": "org-ORG"
},
...
Далі нам треба отриманий результат зберігти в якусь змінну для подальшої роботи.
resty JSON Unmarshall
resty підтримує автоматичний JSON unmarshalling через метод SetResult():
func (r *Request) SetResult(res interface{}) *Request {
if res != nil {
r.Result = getPointer(res)
}
return r
}
Він приймає аргументом тип any (interface{}), передає його до своєї функції getPointer(), де виконується перевірка – чи це тип pointer:
func getPointer(v interface{}) interface{} {
vv := valueOf(v)
if vv.Kind() == reflect.Ptr {
return v
}
...
}
І тоді SetResult() викликаючи parseResponseBody() записує значення з Request.Result до об’єкта, який був переданий аргументом до SetResult():
...
// default after response middlewares
c.afterResponse = []ResponseMiddleware{
parseResponseBody,
saveResponseIntoFile,
}
...
А в функції parseResponseBody() викликається метод Unmarshalc, який в свою чергу викликає Client.JSONUnmarshal(), а поле JSONUnmarshal містить функцію json.Unmarshal():
...
func createClient(hc *http.Client) *Client {
if hc.Transport == nil {
hc.Transport = createTransport(nil)
}
c := &Client{ // not setting lang default values
...
JSONUnmarshal: json.Unmarshal,
...
Див. код resty/v2/client.go.
Отже, ми отримуємо результат в JSON, і через SetResult() можемо зберігти потрібні поля в якийсь об’єкт.
Створення Go struct для JSON Unmarshall
Давайте подумаємо над тим, як ми хочемо сформувати дані.
У нас є project_id та amount – скільки цей проект витратив, це ми отримуємо з OpenAI API /organization/costs.
У нас також є Project Names, які ми можемо отримати з /organization/projects, але про це трохи далі.
В результаті ми можемо побудувати щось таке:
[
{
"project_id": "Id1",
"project_name": "Name1",
"project_spend": 100
},
{
"project_id": "Id2",
"project_name": "Name2",
"project_spend": 200
}
]
Що для цього є в Go?
- array, масив: фіксована довжина, індексований тип, всі об’єкти того самого типу –
[3]int{1,2,3} - slice: аналогічний до array, але не фіксованої довжини –
[]int{1,2,3} - maps: набір key:value елементів змінної довжини одного типу –
map[string]string{"key_name": "value_value"} - structs: комплексний тип, який може включати в себе інші типи –
struct{ Name string; Age int }{ Name: "Nino", Age: 35 }
Так як ми знаємо, які типи ми отримуємо з API та всі поля в них – то нам підійде slice of structs, де кожен елемент slice буде структурою з полями, в яких ми будемо зберігати project_id, amount та project_name.
Структура для Project ID та Amount
Структура може виглядати так:
type ProjectSpend struct {
ProjectID string
ProjectSpend int
}
А потім створимо slice з цією структурою:
data := []ProjectSpend{}
Тепер давайте подивимось на те, що нам повертає OpenAI API.
Від /organization/costs:
{
"object": "page",
"has_more": false,
"next_page": null,
"data": [
{
"object": "bucket",
"start_time": 1763078400,
"end_time": 1763164800,
"results": [
{
"object": "organization.costs.result",
"amount": {
"value": 2.16911625,
"currency": "usd"
},
"line_item": null,
"project_id": "proj_1",
"organization_id": "org-ORG"
},
{
"object": "organization.costs.result",
"amount": {
"value": 0.1846203,
"currency": "usd"
},
"line_item": null,
"project_id": "proj_2",
"organization_id": "org-ORG"
},
...
]
}
]
}
Тут у нас виходить така структура:
- починається з JSON
object {}- має кілька JSON properties –
"object": "page", etc - далі йде масив
data []- який містить в собі інший
object {}- який починається з properties
"object": "bucket", etc - і в якому є інший масив
results []- який включає в себе ще один
object {}- який починається із property
"object": "organization.costs.result" - за яким слідує property
amount, який містить в собі вкладенийobject {}- з двома property –
valueтаvalue
- з двома property –
- який починається із property
- який включає в себе ще один
- який починається з properties
- який містить в собі інший
- має кілька JSON properties –
Якщо ми хочемо це відобразити в Go struct – то нам потрібно створити кілька структур, які будуть передавати дані одна до одної:
- перша структура “захоплює”
data[]- друга структура – отримує
results[]- третя – отримує значення поля
project_id- а четверта – зчитує
amount
- а четверта – зчитує
- третя – отримує значення поля
- друга структура – отримує
Як це може виглядати в коді – з використання structs composition, коли одна структура містить в собі поле, яке є іншою структурою:
type ResponceAmount struct {
Value float64
}
type ResponceProjectID struct {
ProjectID string `json:"project_id"`
Amount ResponceAmount
}
type ResponseResults struct {
Results []ResponceProjectID
}
type ResponseData struct {
Data []ResponseResults
}
res := &ResponseData{}
І тепер можемо виконати json.Unmarshall через виклик SetResult(), в який ме передаємо pointer – res := &ResponseData{}:
...
_, err := client.SetAuthToken(apiKey).
R().SetQueryParams(setQueryParams).
SetResult(res).
Get(baseURL + costsPath)
fmt.Println("Result: ", res)
...
Отримуємо такий результат:
$ go run main.go
...
Result: &{[{[{proj_1 {2.16911625}} {proj_Agtar0XzJdXXLhGt8YCRNZMY {0.1846203}} {proj_2 {0.1531728}} {proj_3 {0.19788874999999997}}]}]}
Або можемо зробити більш лаконічно – використовуючи nested anonymous structs:
...
// catch data[] and pass to nested struct
// catch results[] and pass to next nested struct
// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
// catch 'amount' property to the 'Amount' field, and pass to next nested struct
// finally, catch 'value' property to the 'Value' field
type ResponseData struct {
Data []struct {
Results []struct {
ProjectID string `json:"project_id"`
Amount struct {
Value float64
}
}
}
}
...
І отримаємо той самий результат.
А далі нам потрібно буде згенерувати метрики з лейблами.
Робимо це у два цикли for, в яких перебираємо поля кожної структури:
...
// catch each item from the 'Response.Data[]'
for _, dataItem := range res.Data {
// catch each iteam from the 'Response.Data[].Results[]'
for _, result := range dataItem.Results {
project := result.ProjectID
amount := result.Amount.Value
// print in VictoriaMetrics gauge format
fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
}
}
...
Результат:
$ go run main.go
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
openai_stats{type="costs", project="proj_3"} 0.213558
openai_stats{type="costs", project="proj_4"} 0.198619
А тепер зробимо аналогічно, але для імен проектів, бо мати в лейблах метрик значення у вигляді “proj_123” зовсім незручно, хочеться вивести нормальні імена.
Структура для Project Names
Додаємо другий ендпоінт, див. документацію List projects:
... const ( baseURL = "https://api.openai.com/v1" costsPath = "/organization/costs" projectsPath = "/organization/projects" ) ...
А виконання запитів до OpenAI виносимо в окрему функцію:
...
func getOpenAi(client *resty.Client, path string, out any) error {
_, err := client.R().
SetResult(out).
Get(path)
return err
}
...
Додавання OPENAI_ADMIN_KEY ключа і параметрів переносимо в створення клієнта, після чого викликаємо нашу функцію, якій передаємо створений і налаштований клієнт:
...
func main() {
//client := resty.New()
apiKey := os.Getenv("OPENAI_ADMIN_KEY")
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
setQueryParams := map[string]string{
"start_time": timeNow,
"group_by": "project_id",
}
// use pointer to ResponseData struct
// as 'json.Unmarshal' requires a pointer to write results
costsRes := &CostsResponseData{}
client := resty.New().
SetAuthToken(apiKey).
SetQueryParams(setQueryParams)
getOpenAi(client, baseURL+costsPath, costsRes)
fmt.Println("Result: ", costsRes)
...
Запускаємо:
$ go run main.go
Result: &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
openai_stats{type="costs", project="proj_1"} 2.170784
openai_stats{type="costs", project="proj_2"} 0.241411
...
Тепер переходимо до отримання імен проектів.
Запит до api.openai.com/v1/organization/projects нам поверне дані в такому форматі:
{
"object": "list",
"data": [
{
"id": "proj_abc",
"object": "organization.project",
"name": "Project example",
"created_at": 1711471533,
"archived_at": null,
"status": "active"
}
],
"first_id": "proj-abc",
"last_id": "proj-xyz",
"has_more": false
}
Робимо аналогічно до отримання костів – створюємо структуру:
...
type ProjectsResponse struct {
Data []struct {
ID string
Name string
}
}
...
І в main() додаємо другий виклик getOpenAi() та обробку помилок:
...
// use pointer to ResponseData struct
// as 'json.Unmarshal' requires a pointer to write results
costsRes := &CostsResponseData{}
if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
panic(err)
}
projectsRes := &ProjectsResponse{}
if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
panic(err)
}
fmt.Println("Costs Result: ", costsRes)
fmt.Println("Projects Result: ", projectsRes)
...
Маємо такий результат:
$ go run main.go
Costs Result: &{[{[{proj_1 {2.1707842499999996}} {proj_2 {0.24141089999999998}} {proj_3 {0.21355799999999994}} {proj_4 {0.46123659999999994}}]}]}
Projects Result: &{[{proj_1 Default project} {proj_2 Assistant Test/Eval} {proj_3 Kraken Production} {proj_4 Knowledge Base}]}
...
sanitize імен – форматування даних зі strings.Replace()
Але в іменах у нас є пробіли та символи “/”, і імена проектів містять заглавні букви – а нам в лейблах метрик треба мати вид “my_project_name“.
Додамо функцію, яка буде виконувати нормалізацію використовуючи методи ToLower() та ReplaceAll() із пакету strings:
...
func normalizeLabel(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "_")
s = strings.ReplaceAll(s, "/", "_")
return s
}
...
Наступний крок – побудувати map, в якій ми будемо мати project_id та project_names:
...
projectNames := make(map[string]string)
// get each 'ProjectsResponse.Data[].ID'
// get each 'ProjectsResponse.Data[].Name'
// populate the projectNames map with:
// 'project_id' = 'project_name'
for _, p := range projectsRes.Data {
projectNames[p.ID] = normalizeLabel(p.Name)
}
fmt.Println("Projects Names: ", projectNames)
...
В результаті маємо:
$ go run main.go Projects Names: map[proj_1:kraken_production proj_2:assistant_test_eval proj_3:knowledge_base proj_4:default_project]
І тепер оновлюємо наші два цикли – використовуємо в лейблі імена замість ID:
...
// catch each item from the 'Response.Data[]'
for _, dataItem := range costsRes.Data {
// catch each item from the 'Response.Data[].Results[]'
for _, result := range dataItem.Results {
// get ''Response.Data[].Results[].ProjectID'
id := result.ProjectID
// get ''Response.Data[].Results[].Amount.Value'
amount := result.Amount.Value
// use the 'id' to get the project name from the projectNames map
project := projectNames[id]
if project == "" {
project = "unknown"
}
// print in VictoriaMetrics gauge format
fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
}
}
...
І результат:
$ go run main.go
openai_stats{type="costs", project="knowledge_base"} 2.170784
openai_stats{type="costs", project="kraken_production"} 0.241411
openai_stats{type="costs", project="assistant_test_eval"} 1.083077
openai_stats{type="costs", project="default_project"} 0.461237
Зараз весь код у нас такий:
package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// set global const as ay be used in other packages
const (
baseURL = "https://api.openai.com/v1"
costsPath = "/organization/costs"
projectsPath = "/organization/projects"
)
// catch data[] and pass to nested struct
// catch results[] and pass to next nested struct
// catch 'project_id' property to the 'ProjectID' field, and pass to next nested struct
// catch 'amount' property to the 'Amount' field, and pass to next nested struct
// finally, catch 'value' property to the 'Value' field
type CostsResponseData struct {
Data []struct {
Results []struct {
ProjectID string `json:"project_id"`
Amount struct {
Value float64
}
}
}
}
type ProjectsResponse struct {
Data []struct {
ID string
Name string
}
}
func getOpenAi(client *resty.Client, path string, out any) error {
_, err := client.R().
SetResult(out).
Get(path)
return err
}
func normalizeLabel(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "_")
s = strings.ReplaceAll(s, "/", "_")
return s
}
func main() {
//client := resty.New()
apiKey := os.Getenv("OPENAI_ADMIN_KEY")
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
setQueryParams := map[string]string{
"start_time": timeNow,
"group_by": "project_id",
}
client := resty.New().
SetAuthToken(apiKey).
SetQueryParams(setQueryParams)
// use pointer to ResponseData struct
// as 'json.Unmarshal' requires a pointer to write results
costsRes := &CostsResponseData{}
if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
panic(err)
}
projectsRes := &ProjectsResponse{}
if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
panic(err)
}
projectNames := make(map[string]string)
// get each 'ProjectsResponse.Data[].ID'
// get each 'ProjectsResponse.Data[].Name'
// populate the projectNames map with:
// 'project_id' = 'project_name'
for _, p := range projectsRes.Data {
projectNames[p.ID] = normalizeLabel(p.Name)
}
// catch each item from the 'Response.Data[]'
for _, dataItem := range costsRes.Data {
// catch each item from the 'Response.Data[].Results[]'
for _, result := range dataItem.Results {
// get ''Response.Data[].Results[].ProjectID'
id := result.ProjectID
// get ''Response.Data[].Results[].Amount.Value'
amount := result.Amount.Value
// use the 'id' to get the project name from the projectNames map
project := projectNames[id]
if project == "" {
project = "unknown"
}
// print in VictoriaMetrics gauge format
fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
}
}
}
Тепер можемо переходити до формування реальних метрик та записати їх до VictoriaMetrics.
Планування метрик для VictoriaMetrics
Отже, метрики у нас будуть у вигляді openai_stats{type="costs", project="prodject_id"} 5.55.
А що сказано в задачі, що треба в результаті?
якщо денний спендінг на опенаі перевищує середній за останні дні (з певним трешолдом) – кричати в слак
Значить, нам потрібна буде сума за добу, і маючи її, ми можемо робити порівняння з попередніми періодами часу.
А що нам повертається в API?
Дивимось Costs object:
The aggregated costs details of the specific time bucket.
А що ми маємо, коли робимо запит тільки зі start_time без end_time?
Дивимось час в отриманому response:
...
"start_time": 1762905600,
"end_time": 1762992000,
...
Тут start_time буде:
$ date -d @1762905600 Wed Nov 12 02:00:00 EET 2025
А end_time:
$ date -d @1762992000 Thu Nov 13 02:00:00 EET 2025
Це 00:00 в UTC.
Тобто, повертає суму витрати за сьогоднішній день, за поточну добу, бо цей текст писався 12-го числа.
Отже тут:
...
"start_time": 1762905600,
"end_time": 1762992000,
...
"value": 1.76643575,
"currency": "usd"
},
"line_item": null,
"project_id": "proj_1",
...
Бачимо, що за сьогодні проект з ID “proj_1” витратив 1.76643575 бакси.
Окей…
Як ми можемо це зберігати в метрику? Зробити тип Counter, який постійно збільшується, і кожну годину його оновлювати?
Тоді тайм-серія (див. Що таке Time Series?) по цій метриці буде виглядати якось так:
openai_stats{type="costs", project="prodject_id"}
1762960223 1.76
1762960237 1.80
1762960249 1.95
І потім можемо для алерту створити запит на кшталт такого:
if
avg_over_time(openai_stats{type="costs", project="prodject_id"}[1d)
>
avg_over_time(openai_stats{type="costs", project="prodject_id"}[3d)
then send alert
Але з Counter є нюанс – він обнуляється, якщо експортер перезапуститься – див. counter reset.
Крім того, якщо ми отримуємо дані починаючи з 00:00 – то з наступного дня значення буде починатись з 0,00 USD.
А значить, у нас значення в метриці може і збільшуватись, і зменшуватись, а значить – нам потрібен не Counter, а Gauge.
VictoriaMetrics Go client
Є бібліотека для Prometheus, але так як у нас VictoriaMetrics – то беремо їхній пакет, який до того ж має функцію PushMetrics(), з якою ми можемо відразу пушити метрики до VictoriaMetrics.
Дивимось документацію по type Gauge, там є приклад створення об’єкта метрики.
Функція NewGauge() приймає два аргументи – ім’я метрики з лейблами та функцію, яка виконує оновлення значення для цієї метрики, див. gauge.go:
func NewGauge(name string, f func() float64) *Gauge {
return defaultSet.NewGauge(name, f)
}
Але якщо ми хочемо задавати дані самі – то замість передачі другого аргументу f func() – можемо передати просто nil, а потім використати метод Set().
Пробуємо, як це працює з nil та Set():
gore> :import "github.com/VictoriaMetrics/metrics" gore> g := metrics.NewGauge(`test_gauge`, nil) gore> g.Set(9.00) gore> :import fmt gore> fmt.Println(g.Get()) 9
Супер.
Тепер думає як ми будемо все це діло робити.
Нам потрібно:
- створити
metrics.NewGauge()на кожну метрику - потім в циклі раз на годину отримувати дані з API
- для кожної метрики виконувати
Set()
Тобто метрики генеруємо кожну зі своїм значенням лейбли project:
project_1 := metrics.NewGauge(openai_stats{type="costs", project="prodject_1"})
project_2 := metrics.NewGauge(openai_stats{type="costs", project="prodject_2"})
project_3 := metrics.NewGauge(openai_stats{type="costs", project="prodject_3"})
А потім для кожного project_N виконуємо Set().
У нас зараз є цикл, який заповнює fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount).
Давайте спочатку прямо в нього додамо генерацію метрик, подивимось, як воно може виглядати.
Для виводу на консоль використовуємо функцію metrics.WritePrometheus(), яка пише в Prometheus-форматі в канал, який задається першим аргументом.
Після циклів додаємо:
...
// print in VictoriaMetrics gauge format
//fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
gauge := metrics.NewGauge(metricName, nil)
gauge.Set(amount)
}
}
metrics.WritePrometheus(os.Stdout, false)
...
В результаті маємо такий результат:
$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 4.9838991
test_openai_stats{type="costs", project="default_project"} 0.5281144000000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.5510669499999999
Супер.
А тепер подумаємо над всією логікою виконання.
Що у нас є зараз:
- створення
resty.Client - ініціалізація структури
costsRes := &CostsResponseData{} - виклик
getOpenAi()з аргументами (client, baseURL+costsPath, costsRes), де ми заповнюємо дані в структуріCostsResponseData - ініціалізація
projectsRes := &ProjectsResponse{} - виклик
getOpenAi()з аргументами (client, baseURL+projectsPath, projectsRes), де ми заповнюємо дані в структуріProjectsResponse - ініціалізація мапи
projectNames - заповнення її з даними
"project_id": "project_name" - далі цикли, в яких:
- отримуємо
project_id - отримуємо
amount - по
project_idотримуємо ім’я проекту, записуємо в зміннуproject - генеруємо ім’я метрики і лейблу з
projectвmetricName - з
metrics.NewGaugeгенеруємо нову метрику - з
gauge.Set(amount)записуємо в неї значення
- отримуємо
- з
metrics.WritePrometheus()всі згенеровані метрики виводимо на консоль
І все це зараз виконується при виклику main().
Натомість нам при виклику main(), тобто при старті експортера, треба:
- створити
resty.Client - далі періодично виконувати оновлення даних та записувати дані до VictoriaMetrics:
- з
getOpenAi()заповнити структуруProjectsResponse - з
getOpenAi()заповнити структуруCostsResponseData - заповнити
projectNames - запустити цикли для генерації метрик і виконання
Set() - в кінці циклу виконати
WritePrometheus()
- з
Правда, при такому підході ми кожну годину будемо перезаписувати поля в ProjectsResponse, CostsResponseData та projectNames, що наче не дуже ОК з точки зору перформансу – але якщо у нас з’явиться новий проект, то ми його відразу “спіймаємо”, і додамо нову метрику для нього.
Отже, що треба зробити – це винести нашу логіку в окрему функцію, раз на годину викликати її, а потім виконувати WritePrometheus().
Пишемо цю функцію, тільки міняємо NewGauge() на GetOrCreateGauge(), бо при наступному виклику нашої функції метрики вже будуть створені:
...
func fetchAndPush(client *resty.Client, costsRes *CostsResponseData, projectsRes *ProjectsResponse, projectNames map[string]string) {
if err := getOpenAi(client, baseURL+costsPath, costsRes); err != nil {
panic(err)
}
if err := getOpenAi(client, baseURL+projectsPath, projectsRes); err != nil {
panic(err)
}
// get each 'ProjectsResponse.Data[].ID'
// get each 'ProjectsResponse.Data[].Name'
// populate the projectNames map with:
// 'project_id' = 'project_name'
for _, p := range projectsRes.Data {
projectNames[p.ID] = normalizeLabel(p.Name)
}
// catch each item from the 'Response.Data[]'
for _, dataItem := range costsRes.Data {
// catch each item from the 'Response.Data[].Results[]'
for _, result := range dataItem.Results {
// get 'Response.Data[].Results[].ProjectID'
// i.e. 'proj_123'
id := result.ProjectID
// get 'Response.Data[].Results[].Amount.Value'
amount := result.Amount.Value
// use the 'id' to get the project name from the projectNames map
project := projectNames[id]
if project == "" {
project = "unknown"
}
// print in VictoriaMetrics gauge format
//fmt.Printf("openai_stats{type=\"costs\", project=\"%s\"} %f\n", project, amount)
metricName := fmt.Sprintf(`test_openai_stats{type="costs", project="%s"}`, project)
gauge := metrics.GetOrCreateGauge(metricName, nil)
gauge.Set(amount)
}
}
metrics.WritePrometheus(os.Stdout, false)
}
...
Тепер в main() у нас залишається:
...
func main() {
//client := resty.New()
apiKey := os.Getenv("OPENAI_ADMIN_KEY")
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
setQueryParams := map[string]string{
"start_time": timeNow,
"group_by": "project_id",
}
client := resty.New().
SetAuthToken(apiKey).
SetQueryParams(setQueryParams)
// use pointer to ResponseData struct
// as 'json.Unmarshal' requires a pointer to write results
costsRes := &CostsResponseData{}
projectsRes := &ProjectsResponse{}
// will be populated with key:value pairs:
// 'proj_123' = 'kraken_production'
projectNames := make(map[string]string)
fetchAndPush(client, costsRes, projectsRes, projectNames)
}
Запускаємо для перевірки:
$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.3417053
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17244425
test_openai_stats{type="costs", project="kraken_production"} 0.6170747
Тепер нам треба замість простого виводу на консоль записати дані до VictoriaMetrics.
Запис метрик до VictoriaMetrics з InitPush() та PushMetrics()
Для запису метрик до VictoriaMetrics маємо дві основні функції – InitPush() та PushMetrics().
Функція InitPush()
Функція InitPush() дозволяє виконувати періодичні записи із заданим interval, а PushMetrics() – просто разово записати всі метрики, які є в Set struct. Про Set трохи далі.
Тепер просто інтересу заради розберемо, як саме VictoriaMetrics клієнт виконує запис.
Знаходимо код InitPush():
func InitPush(pushURL string, interval time.Duration, extraLabels string, pushProcessMetrics bool) error {
writeMetrics := func(w io.Writer) {
WritePrometheus(w, pushProcessMetrics)
}
return InitPushExt(pushURL, interval, extraLabels, writeMetrics)
}
Тут:
- ми в нашому коді викликаємо
InitPush(), передаємо до цієї функції URL та інтервал InitPush()створює зміннуwriteMetrics– анонімну функцію, яка приймає аргумент типуio.Writer, і яка потім буде викликати функціюWritePrometheus(), в яку передається цейio.Writer- далі викликається функція
InitPushExt(), якій передаєтьсяpushURL,interval, та об’єктwriteMetrics
Дивимось на InitPushExt():
func InitPushExt(pushURL string, interval time.Duration, extraLabels string, writeMetrics func(w io.Writer)) error {
opts := &PushOptions{
ExtraLabels: extraLabels,
}
return InitPushExtWithOptions(context.Background(), pushURL, interval, writeMetrics, opts)
}
Тут просто додаються параметри зі структури PushOptions, в яку можемо передати параметри типу extraLabels, і потім викликається InitPushExtWithOptions(), в яку передається наш writeMetrics.
Дивимось InitPushExtWithOptions(): тут створюється goroutine, яка із заданим interval викликає pushMetrics(), в яку передається наш об’єкт writeMetrics (тобто та анонімна функція, яка буде викликати WritePrometheus()):
func InitPushExtWithOptions(ctx context.Context, pushURL string, interval time.Duration, writeMetrics func(w io.Writer), opts *PushOptions) error {
pc, err := newPushContext(pushURL, opts)
...
go func() {
ticker := time.NewTicker(interval)
...
ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
err := pc.pushMetrics(ctxLocal, writeMetrics)
В свою чергу pushMetrics() створює буфер bytes.Buffer, передає його до writeMetrics(), writeMetrics() викликає WritePrometheus(), яка отримує цей буфер:
func (pc *pushContext) pushMetrics(ctx context.Context, writeMetrics func(w io.Writer)) error {
bb := getBytesBuffer()
defer putBytesBuffer(bb)
writeMetrics(bb)
...
І потім WritePrometheus() записує зібрані метрики в цей буфер:
// WritePrometheus writes all the metrics from s to w in Prometheus format.
func (s *Set) WritePrometheus(w io.Writer) {
...
А далі з цього буферу (все ще в pushMetrics() створюється request body, задаються headers:
І потім виконується відправка даних до переданого URL:
Тепер повернемось до “WritePrometheus() записує зібрані метрики в цей буфер“.
WritePrometheus() – це метод структури Set:
func (s *Set) WritePrometheus(w io.Writer) {
...
}
А Set створюється, коли ми викликаємо NetGauge():
func NewGauge(name string, f func() float64) *Gauge {
return defaultSet.NewGauge(name, f)
}
defaultSet – це виклик NewSet():
var defaultSet = NewSet()
А NewSet() заповнює структуру Set:
// NewSet creates new set of metrics.
//
// Pass the set to RegisterSet() function in order to export its metrics via global WritePrometheus() call.
func NewSet() *Set {
return &Set{
m: make(map[string]*namedMetric),
}
}
Тобто, при виклику NetGauge() ми передаємо аргумент з іменем метрики, NetGauge() викликає NewSet(), передає цю метрику, а NewSet() виконує ініціалізацію структури Set, в поле namedMetric задаючи нашу метрику.
Функція PushMetrics()
Ну а з PushMetrics() все майже аналогічно – створюється writeMetrics, викликається PushMetricsExt():
func PushMetrics(ctx context.Context, pushURL string, pushProcessMetrics bool, opts *PushOptions) error {
writeMetrics := func(w io.Writer) {
WritePrometheus(w, pushProcessMetrics)
}
return PushMetricsExt(ctx, pushURL, writeMetrics, opts)
}
А PushMetricsExt() викликає pushMetrics(), але тільки один раз, а не в циклі:
func PushMetricsExt(ctx context.Context, pushURL string, writeMetrics func(w io.Writer), opts *PushOptions) error {
pc, err := newPushContext(pushURL, opts)
if err != nil {
return err
}
return pc.pushMetrics(ctx, writeMetrics)
}
Окей – повертаємось до нашого коду.
Отже, що нам треба зробити зараз – це замість WritePrometheus() викликати PushMetrics().
Створення context та виклик PushMetrics()
Для PushMetrics() потрібно передати context, який керує goroutines і завершує їх або по таймауту, або якщо сама програма отримала від системи сигнали SIGTERM чи SIGKILL.
Детальніше про context трохи далі, поки просто додаємо import "context", в main() створюємо пустий контекст з Background():
...
import (
"context"
...
func main() {
...
// will be populated with key:value pairs:
// 'proj_123' = 'kraken_production'
projectNames := make(map[string]string)
ctx := context.Background()
...
В нашій функції fetchAndPush() додаємо параметр з типом context.Context:
...
func fetchAndPush(ctx context.Context, ...) {
...
}
Додаємо передачу контексту до виклику fetchAndPush():
...
fetchAndPush(ctx, client, costsRes, projectsRes, projectNames)
...
Задаємо змінну з URL VictoriaMetrics, міняємо metrics.WritePrometheus() на metrics.PushMetrics(), до якої передаємо отриманий з main() context:
...
//metrics.WritePrometheus(os.Stdout, false)
pushURL := "http://localhost:8428/api/v1/import/prometheus"
if err := metrics.PushMetrics(ctx, pushURL, false, nil); err != nil {
panic(err)
}
}
В принципі майже останнє, що залишилось – це виконувати нашу функцію з якимось інтервалом.
gocron – запуск задач за розкладом
Є приємний пакет gocron, додаємо його, поки тестуємо – ставимо запуск кожну хвилину:
import (
...
"github.com/go-co-op/gocron"
...
)
...
func main() {
s := gocron.NewScheduler(time.Local)
s.Every(1).Minute().Do(func() {
fetchAndPush(client, costsRes, projectsRes, projectNames)
})
s.StartBlocking()
}
Потім можна переробити на виклик раз на годину – s.Every(1).Hour().Do( ... ), або на початку кожної години – s.Cron("0 * * * *").Do( ... ).
І в кінці запускаємо крон зі StartBlocking(), який блокує завершення самої функції main().
Відкриваємо доступ до VictoriaMetrics в Kubernetes:
$ kk -n ops-monitoring-ns port-forward svc/vmsingle-vm-k8s-stack 8428
Запускаємо наш експортер:
$ go run main.go
test_openai_stats{type="costs", project="assistant_test_eval"} 6.501765299999999
test_openai_stats{type="costs", project="default_project"} 0.6592560500000001
test_openai_stats{type="costs", project="knowledge_base"} 2.17411225
test_openai_stats{type="costs", project="kraken_production"} 0.6471627999999999
^Csignal: interrupt
І перевіряємо дані вже у VictoriaMetrics:
Правда, тут з’явився якийсь “unknown” проект, треба буде додати логування.
Що ще треба поправити:
- зараз ініціалізація структур
CostsResponseDataтаProjectsResponseвиконується вmain(), і потім при кожному викликуfetchAndPush()в них записуються дані- якщо проект видалиться з OpenAI – він залишиться в структурах, і ми будемо продовжувати писати метрики для проекту, якого вже нема
- треба винести в саму
fetchAndPush()і просто кожного разу заповнювати їх з нуля
- аналогічно з
projectNames– перенести ініціалізацію в самуfetchAndPush() SetQueryParams– зараз передається однаково для обох викликівgetOpenAi(), але в/organization/projectsнема параметраgroup_by- в метриці лейблу
type=""краще замінити наcategory="" - додати external lablels – щось типу “
job="openai-exporter"“ - замість використання
panic(err)– записувати в лог, повертати помилку до викликаючої функції і обробляти там - додати коректну обробку сигналів
SIGTERMтаSIGINT resty.clientвміє виконувати retry при помилках, треба додатиSetRetryCount()іSetRetryWaitTime()- ну і додати логи виконання і помилок
Створення Golang context
Під час роботи в нашому коді запускається кілька одночасних операцій – з gocron.NewScheduler() ми запускаємо виконання нашої функції fetchAndPush(), в ній у нас запускаються HTTP-запити з resty.Client.Get(), у VictoriaMetrics запускаються виклики для запису до VictoriaMetrics endpoint.
Аби все це діло коректно завершити, а не просто “вбити” під час отримання SIGINT або SIGTERM – Go дозволяє нам керувати процесом завершення наших функцій і goroutines через context виконання.
Інший приклад, коли нам треба керувати виконанням операції – це задати ліміт на час виконання, як це, наприклад, зроблено в VictoriaMetrics у функції InitPushExtWithOptions():
...
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
stopCh := ctx.Done()
for {
select {
case <-ticker.C:
ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
err := pc.pushMetrics(ctxLocal, writeMetrics)
...
Тут виконання pc.pushMetrics() обмежено interval, який передається при виклику InitPush().
При цьому context виконання включає в себе не тільки обробку сигналів і керування життєвим циклом функцій і goroutine, но і всю пов’язану з цим виконанням інформацію:
Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes
Я виніс описання роботи context окремою частиною, бо дуже цікавий механізм, а зараз просто давайте його додамо в наш код.
Отже, що нам треба:
- створити context
- створити “перехоплювач сигналів”
SIGINT(Ctrl+C) таSIGTERM(сигнал від операційної системи, коли виконання програми завершується, наприклад – коли kubelet зупиняє контейнер) - відправити сигнал зупинки всім дочірнім функціям і goroutines
- завершити виконання
main()
Для цього замість виклику context.Background() в main() використовуємо signal.NotifyContext(), який отримує потрібні системні виклики і відправляє сигнал про зупинку всім пов’язаним задачам (як саме – див. далі у Bonus: як працює контроль виконання в context):
...
rootCtx, rootCancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer rootCancel()
...
Далі описуємо запуск gocron.NewScheduler(), а в кінці main() запускаємо створення та читання з каналу:
... // block until Ctrl+C cancels rootCtx <-rootCtx.Done() }
Як тільки NotifyContext() отримає SIGTERM – він закриє канал rootCtx.Done(), після чого каскадно закриються канали всіх дочірніх контекстів, потім всі дочірні goroutines, що слухають ці контексти, завершать роботу, і main() зможе коректно завершитись.
resty.client теж вміє працювати з context через SetContext(), йому передаємо наш rootCtx при виклику if err := getOpenAI(ctx, ... ) {...}.
Фінальний результат
Після всіх правок весь код експортеру тепер виглядає так:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/go-co-op/gocron"
"github.com/go-resty/resty/v2"
)
const (
// base URL of the OpenAI Admin API
baseURL = "https://api.openai.com/v1"
// endpoints that we call
costsPath = "/organization/costs"
projectsPath = "/organization/projects"
// VictoriaMetrics push endpoint (Prometheus remote write format)
pushURL = "http://localhost:8428/api/v1/import/prometheus"
)
// structure describing the JSON for costs API
// resty will unmarshal into this struct automatically
type CostsResponseData struct {
Data []struct {
Results []struct {
ProjectID string `json:"project_id"`
Amount struct {
Value float64 `json:"value"`
} `json:"amount"`
} `json:"results"`
} `json:"data"`
}
// structure describing the JSON for projects API
// used to map project_id → readable project name
type ProjectsResponse struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
// normalizeLabel converts a project name into a Prometheus-safe label
// - lowercases
// - replaces spaces with underscores
// - replaces slashes to avoid label parser issues
func normalizeLabel(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "_")
s = strings.ReplaceAll(s, "/", "_")
return s
}
// getOpenAI performs a GET request to the OpenAI Admin API
// and unmarshals the returned JSON into the 'out' structure.
//
// ctx: allows cancellation (we pass rootCtx so Ctrl+C cancels requests)
// client: the resty client with authentication
// path: "/organization/costs" or "/organization/projects"
// params: optional query parameters
func getOpenAI(ctx context.Context, client *resty.Client, path string, params map[string]string, out any) error {
// create HTTP request object
req := client.R().
SetContext(ctx). // attach context so cancellation works
SetResult(out) // register target structure for unmarshalling JSON
// set optional query parameters
if params != nil {
req.SetQueryParams(params)
}
// execute HTTP GET request
if _, err := req.Get(baseURL + path); err != nil {
return fmt.Errorf("request to %s failed: %w", path, err)
}
return nil
}
// fetchAndPush performs one exporter cycle:
//
// 1. fetch costs grouped by project_id
// 2. fetch readable project names
// 3. build project_id → normalized_name map
// 4. create/update Prometheus gauges
// 5. push all metrics to VictoriaMetrics
//
// ctx: the root context (cancelled when Ctrl+C is pressed)
func fetchAndPush(ctx context.Context, client *resty.Client) error {
// create fresh response holders for every iteration
costsRes := &CostsResponseData{}
projectsRes := &ProjectsResponse{}
projectNames := make(map[string]string)
// build query parameters for costs API
// start_time: current timestamp (Unix)
// group_by: instruct API to group costs per project_id
timeNow := strconv.FormatInt(time.Now().Unix(), 10)
costParams := map[string]string{
"start_time": timeNow,
"group_by": "project_id",
}
// fetch costs data
if err := getOpenAI(ctx, client, costsPath, costParams, costsRes); err != nil {
return fmt.Errorf("fetch costs: %w", err)
}
// fetch project definitions
if err := getOpenAI(ctx, client, projectsPath, nil, projectsRes); err != nil {
return fmt.Errorf("fetch projects: %w", err)
}
// fill map: project_id → normalized_label
for _, p := range projectsRes.Data {
projectNames[p.ID] = normalizeLabel(p.Name)
}
// process returned costs
for _, dataItem := range costsRes.Data {
for _, result := range dataItem.Results {
id := result.ProjectID
amount := result.Amount.Value
// resolve project readable name
project := projectNames[id]
if project == "" {
project = "unknown"
}
metricName := fmt.Sprintf(
`openai_stats{project="%s",category="costs"}`,
project,
)
// get or create gauge
gauge := metrics.GetOrCreateGauge(metricName, nil)
// update gauge value
gauge.Set(amount)
// log written metric
log.Printf("metric updated: name=%s value=%f", metricName, amount)
}
}
// push metrics with job="openai_exporter"
pushOpts := &metrics.PushOptions{
ExtraLabels: `job="openai_exporter"`,
}
// push all collected metrics
if err := metrics.PushMetrics(ctx, pushURL, false, pushOpts); err != nil {
return fmt.Errorf("push metrics: %w", err)
}
return nil
}
func main() {
// create a context that automatically cancels on OS signals (Ctrl+C, kill, SIGTERM)
//
// how it works:
// - signal.NotifyContext wraps the parent context and subscribes it to OS signals
// - when the program receives Ctrl+C (SIGINT) or SIGTERM:
// Go internally calls rootCancel()
// the context's Done() channel is closed
// - all goroutines waiting on <-rootCtx.Done() are instantly unblocked
// - any operation bound to this context (HTTP requests, timeouts, jobs)
// receives ctx.Err()==context.Canceled and stops gracefully
//
// practically:
// - main goroutine waits for <-rootCtx.Done()
// - when Ctrl+C arrives => rootCtx.Done() closes => program starts graceful shutdown
//
// 'defer rootCancel()' is used to clean up internal signal resources when main() exits normally
rootCtx, rootCancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer rootCancel()
// load OpenAI admin API key
apiKey := os.Getenv("OPENAI_ADMIN_KEY")
if apiKey == "" {
log.Fatal("OPENAI_ADMIN_KEY is not set")
}
// create resty client with:
// - bearer token
// - automatic retries (3 attempts)
client := resty.New().
SetAuthToken(apiKey).
SetRetryCount(3).
SetRetryWaitTime(2 * time.Second)
// create scheduler using local timezone
s := gocron.NewScheduler(time.Local)
// register a job that runs every 1 minute
s.Every(1).Minute().Do(func() {
start := time.Now()
log.Println("starting fetch-and-push cycle")
// run our exporter cycle
if err := fetchAndPush(rootCtx, client); err != nil {
log.Println("ERROR during fetchAndPush:", err)
return
}
log.Println("fetch-and-push completed in", time.Since(start))
})
log.Println("starting scheduler...")
// run scheduler in background goroutine
s.StartAsync()
// block until Ctrl+C cancels rootCtx
<-rootCtx.Done()
log.Println("received Ctrl+C, stopping scheduler...")
// shutdown scheduler gracefully
s.Stop()
log.Println("scheduler stopped, exiting")
}
Перевіряємо у VictoriaMetrics:
І порівняємо з даними в самому OpenAI на сторінці platform.openai.com/settings/organization/usage:
Ті самі 6.95 долари, що ми бачимо у VictoriaMetrics від нашого експортеру.
Можна б ще покращити код, наприклад розбити велику функцію fetchAndPush(), і треба додати передачу URL до VictoriaMetrics зі змінних оточення, але поки поживемо з таким варіантом.
Bonus: як працює контроль виконання через Golang context
Ми в нашій функції fetchAndPush() використовуємо metrics.PushMetrics(), передавши йому контекст.
Але для кращої картини – давайте знову повернемося до InitPush(), бо там використання context більш явне.
Отже, InitPush() викликає InitPushExt(), а InitPushExt() викликає InitPushExtWithOptions(), якому передає пустий context.Background() – return InitPushExtWithOptions(context.Background() ...).
В InitPushExtWithOptions() запускається goroutine, go func() {}, в якій створюється локальний context :
...
ctxLocal, cancel := context.WithTimeout(ctx, interval+time.Second)
err := pc.pushMetrics(ctxLocal, writeMetrics)
...
Закриття каналу з cancel()
При виклику функції context.WithTimeout() відбувається наступний процес:
WithTimeout()викликаєWithDeadline()WithDeadline()викликаєWithDeadlineCause():- де створюється об’єкт структури
c := &timerCtx{} - далі
WithDeadlineCause()перевіряє умовуif dur <= 0і, і якщо час виконання завершився, то:-
викликає
c.cancel(true, DeadlineExceeded, cause) -
повертає “
return c, func() { c.cancel(false, Canceled, nil) }“, яка повертається доInitPushExtWithOptions()в частиніctxLocal, cancel := context.WithTimeout() і "func() { c.cancel() }"і стаєcancel()c.cancel()– це метод структуриtimerCtx–func (c *timerCtx) cancel(), який викликаєc.cancelCtx.cancel()- а
c.cancelCtx.cancel()– це метод структуриcancelCtx–func (c *cancelCtx) cancel(), який викликаєd, _ := c.done.Load().(chan struct{})- і викликає
close(d)
- і викликає
-
- де створюється об’єкт структури
Ось тут:
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
...
d, _ := c.done.Load().(chan struct{})
if d == nil {
...
} else {
close(d)
}
...
c.done.Load() – викликається з поля done структури cancelCtx:
type cancelCtx struct {
...
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
...
}
Де Load() це метод структури Value:
func (v *Value) Load() (val any)
Тобто, в d, _ := c.done.Load().(chan struct{}) викликається Load(), в (chan struct{}) виконується type assertion, тобто перевіряється, що це тип chan struct{}, після чого d стає chan struct{}, після чого виконується close(channel).
А close() – це вбудована функція Go, яка закриває отриманий аргументом канал.
Як тільки канал Done() закривається – всі goroutines, які виконують <-ctx.Done(), миттєво пробуджуються і можуть коректно завершити свою роботу.
В InitPushExtWithOptions() це виконується тут:
go func() {
...
stopCh := ctx.Done()
...
case <-stopCh:
...
return
}
}
}()
Закриття каналу – це читання нульового значення, що призводить до спрацювання умови case => що призводить до завершення циклу через виклик return => що призводить до завершення всієї go func() {}.
Окей.
А звідки канал взявся?
Відкриття каналу
Для того, аби функція чи рутина постійно “слухали” цей канал в очікуванні його закриття – ми викликаємо Done():
...
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
stopCh := ctx.Done()
...
case <-stopCh:
if wg != nil {
wg.Done()
}
return
}
...
А ctx.Done() – це метод інтерфейсу Context interface:
type Context interface {
...
Done() <-chan struct{}
}
В якій і створюється сам канал:
...
func (c *cancelCtx) Done() <-chan struct{} {
...
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
...
Отже, коли викликається:
context.WithTimeout()=>WithDeadline()=>WithDeadlineCause()=>c := &timerCtx{}яка маєcancelCtx- а
cancelCtx{}має методDone()
- а
І коли ми викликаємо
...
rootCtx, rootCancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
...
То в signal.NotifyContext() ми передаємо пустий контекст, а signal.NotifyContext() створює і повертає власний контекст з context.WithCancel():
...
func NotifyContext(parent context.Context, signals ...os.Signal) (ctx context.Context, stop context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
c := &signalCtx{
...
}
...
return c, c.stop
}
...
Тому в кінці нашої main() ми можемо викликати читання з каналу:
... // block until Ctrl+C cancels rootCtx <-rootCtx.Done() ...
А як тільки канал закриється – управління повертається до main(), виконується gocron.Stop(), і виконання програми завершується.




